Updated
Set up Better Auth with Supabase Declarative Schema
Store Better Auth users and sessions in Supabase Postgres. Create the tables with the Supabase CLI and declarative schemas, then deploy them to your project.
This tutorial demonstrates how to set up Better Auth with Supabase and manage its tables using declarative schemas. Better Auth and Supabase work well together, because every Supabase project is a full Postgres database that Better Auth connects to directly.
In this guide, you build a TanStack Start app with email and password sign-in. The database setup works the same in any framework. Only the route handler and client code change. Better Auth documents those for each framework in its installation guide.
Prerequisites
- Node.js 22 or later
- pnpm installed
- A Supabase project, to deploy to
- A container runtime on your machine.
This guide uses Supabase CLI v3. It installs as a development dependency, so you run it with pnpm supabase.
Set up the project
Create the app, start a local Supabase stack, and set the environment variables that Better Auth reads.
Create the app
-
Create a TanStack Start app and move into its directory:
pnpm dlx @tanstack/cli@latest create my-app --package-manager pnpm --yes --no-intentcd my-appThe
--yesflag accepts the default options instead of prompting for them. The--no-intentflag skips TanStack’s optional setup for AI coding agents. -
Install Better Auth, the Postgres driver, Kysely, and the Supabase CLI. Better Auth uses Kysely to connect to Postgres:
pnpm add better-auth pg kyselypnpm add -D @types/pg supabase
Start Supabase locally
First, start your container runtime.
Then, initialize Supabase in your project and start the local stack:
pnpm supabase initpnpm supabase startWhen the stack is running, the CLI prints its connection details. The local database URL is:
postgresql://postgres:postgres@127.0.0.1:54322/postgresSet environment variables
-
Generate a secret that Better Auth uses to sign session cookies:
pnpm dlx auth@latest secret -
In your project root, create a
.envfile with the secret, the local database URL, and your app’s URL:DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgresBETTER_AUTH_SECRET=<your-generated-secret>BETTER_AUTH_URL=http://localhost:3000
Create the Better Auth tables
Declarative schema files in supabase/schemas/ describe the state you want your database to be in. The Supabase CLI compares them with your migrations and writes the difference as a new migration.
Configure Better Auth
Create src/lib/auth.ts. This file configures Better Auth with the Kysely PostgresDialect and keeps its tables in a better_auth schema:
import { betterAuth } from 'better-auth'import { tanstackStartCookies } from 'better-auth/tanstack-start'import { PostgresDialect } from 'kysely'import { Pool } from 'pg'export const auth = betterAuth({ database: { dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL, }), }), type: 'postgres', schemaName: 'better_auth', }, emailAndPassword: { enabled: true, }, plugins: [tanstackStartCookies()],})The schemaName option keeps the tables in their own schema, outside the database’s default schema. Better Auth qualifies every query with the schema name, such as "better_auth"."user".
The tanstackStartCookies plugin sets cookies through TanStack Start. Other frameworks don’t need it.
Generate the Better Auth schema
Use the Better Auth CLI to write its tables to a schema file:
pnpm dlx auth@latest generate --output supabase/schemas/better_auth.sql --yesYou can ignore the Database schema mismatch error that the command logs. It lists the Better Auth tables that don’t exist yet, which the generated file creates.
The generated file in supabase/schemas/better_auth.sql creates the schema and each table with a schema-qualified name:
create schema if not exists "better_auth";create table "better_auth"."user" ( ... );create table "better_auth"."session" ( ... );create table "better_auth"."account" ( ... );create table "better_auth"."verification" ( ... );If your local database already has Better Auth tables, generate writes only what’s missing, and the schema file is incomplete. With the Kysely adapter, the command compares against your live database. To add changes later, see Update the schema.
Generate and apply the migration
-
Generate a migration from your schema files, then apply it to your local database:
pnpm supabase db schema declarative sync -f add_better_auth --name add_better_auth --no-applypnpm supabase migration up -
Confirm that the database matches what Better Auth expects:
pnpm dlx auth@latest check schemaThe command prints
Schema check passed against the live database. -
Commit
supabase/schemas/better_auth.sqland the migration.
Add sign-up and sign-in
Connect your app to Better Auth with a server route, a client, and a sign-in page.
Mount the auth handler
Better Auth serves its endpoints, such as /api/auth/sign-up/email, through a single handler. In TanStack Start, mount it as a server route.
Create src/routes/api/auth/$.ts:
import { createFileRoute } from '@tanstack/react-router'import { auth } from '@/lib/auth'export const Route = createFileRoute('/api/auth/$')({ server: { handlers: { GET: ({ request }) => auth.handler(request), POST: ({ request }) => auth.handler(request), }, },})Add the sign-in page
-
Create
src/lib/auth-client.tswith the Better Auth client:import { createAuthClient } from 'better-auth/react'export const authClient = createAuthClient() -
In
src/lib/auth.functions.ts, create a server function that reads the current session from the request cookies:import { createServerFn } from '@tanstack/react-start'import { getRequestHeaders } from '@tanstack/react-start/server'import { auth } from '@/lib/auth'export const getSession = createServerFn({ method: 'GET' }).handler(async () => {const headers = getRequestHeaders()return auth.api.getSession({ headers })}) -
Replace
src/routes/index.tsxwith a page that loads the session on the server. The page shows a sign-up and sign-in form, or the signed-in user’s email. It uses Tailwind CSS classes, which the TanStack Start starter includes:import { createFileRoute, useRouter } from '@tanstack/react-router'import { useState } from 'react'import { authClient } from '@/lib/auth-client'import { getSession } from '@/lib/auth.functions'export const Route = createFileRoute('/')({loader: () => getSession(),component: Home,})const inputClass = 'w-full rounded-md border border-neutral-300 px-3 py-2 text-sm'const buttonClass = 'flex-1 rounded-md px-4 py-2 text-sm font-medium'function Home() {const session = Route.useLoaderData()const router = useRouter()const [name, setName] = useState('')const [email, setEmail] = useState('')const [password, setPassword] = useState('')const [error, setError] = useState<string | null>(null)if (session) {return (<main className="mx-auto my-16 max-w-sm rounded-lg border border-neutral-200 bg-white p-6 text-neutral-900 shadow-sm"><p className="text-sm text-neutral-600">Signed in as</p><p className="mt-1 font-medium">{session.user.email}</p><buttonclassName={`${buttonClass} mt-6 w-full border border-neutral-300 hover:bg-neutral-100`}onClick={async () => {await authClient.signOut()router.invalidate()}}>Sign out</button></main>)}async function handleSignUp() {const { error } = await authClient.signUp.email({ name, email, password })if (error) return setError(error.message ?? 'Sign-up failed')router.invalidate()}async function handleSignIn() {const { error } = await authClient.signIn.email({ email, password })if (error) return setError(error.message ?? 'Sign-in failed')router.invalidate()}return (<main className="mx-auto my-16 flex max-w-sm flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-6 text-neutral-900 shadow-sm"><h1 className="mb-2 text-lg font-semibold">Sign in or create an account</h1><inputclassName={inputClass}placeholder="Name"value={name}onChange={(e) => setName(e.target.value)}/><inputclassName={inputClass}placeholder="Email"value={email}onChange={(e) => setEmail(e.target.value)}/><inputclassName={inputClass}placeholder="Password"type="password"value={password}onChange={(e) => setPassword(e.target.value)}/>{error && <p className="text-sm text-red-600">{error}</p>}<div className="mt-2 flex gap-3"><buttonclassName={`${buttonClass} bg-neutral-900 text-white hover:bg-neutral-700`}onClick={handleSignIn}>Sign in</button><buttonclassName={`${buttonClass} border border-neutral-300 hover:bg-neutral-100`}onClick={handleSignUp}>Sign up</button></div></main>)}
For how to protect routes and server functions, see Protecting resources in the Better Auth TanStack Start guide.
Test sign-up locally
Test the sign-up flow against your local Supabase stack:
-
Start the dev server:
pnpm dev -
Open your app at
http://localhost:3000, and sign up with a name, email, and password. The page showsSigned in aswith your email. -
Check that the user is stored in the
better_authschema:pnpm supabase db query 'select id, email from better_auth."user"'You can also view the tables in your local Supabase Studio at
http://127.0.0.1:54323. In the Table Editor, select thebetter_authschema.
Deploy and update the schema
Push the tables to your hosted project. When the Better Auth schema changes later, update the schema file and deploy again.
Deploy to your Supabase project
-
Sign in to the Supabase CLI and link your project:
pnpm supabase loginpnpm supabase link -
Push your migrations to the hosted database. The CLI asks you to confirm before it changes your project:
pnpm supabase db push --remote -
Where your app runs, set the following environment variables:
DATABASE_URL: the connection string from your project’s Connect panelBETTER_AUTH_SECRET: a new secret for production, generated withpnpm dlx auth@latest secretBETTER_AUTH_URL: your app’s public URL
Choose the DATABASE_URL connection type based on where your app runs:
| Where your app runs | Connection type |
|---|---|
| Serverless platforms, such as Vercel or Netlify | Shared pooler, transaction mode |
| A long-running server on an IPv4-only network | Shared pooler, session mode |
| A long-running server with IPv6 or the IPv4 add-on | Direct connection |
On serverless platforms, set max: 1 in the Pool options in src/lib/auth.ts. Kysely with node-postgres doesn’t use prepared statements, so it works with transaction mode without extra settings. For details on each connection type, see Connect to your database.
Update the schema
Better Auth plugins, and some Better Auth upgrades, add tables or columns. Because generate inspects your live database, it outputs only the changes. Use it to update your schema file:
-
In
src/lib/auth.ts, update the Better Auth configuration. For example, add a plugin. -
Generate the changes against your local database, which has the current schema applied. Write them to a temporary file:
pnpm dlx auth@latest generate --output better_auth_changes.sql --yesFor example, adding the Better Auth
usernameplugin generates:alter table "better_auth"."user" add column "username" text;alter table "better_auth"."user" add column "displayUsername" text;create unique index "user_username_uidx" on "better_auth"."user" ("username"); -
Append the changes to your schema file, and delete the temporary file:
cat better_auth_changes.sql >> supabase/schemas/better_auth.sqlrm better_auth_changes.sql -
Generate and apply a new migration:
pnpm supabase db schema declarative sync -f add_better_auth_username --no-applypnpm supabase migration up -
Deploy the migration with
pnpm supabase db push --remote.
Changes made with pnpm dlx auth@latest migrate apply directly to the database, so the Supabase CLI doesn’t include them in your migrations. Make schema changes in the declarative schema files instead, and don’t run migrate in this setup.
Read and write app data
Better Auth signs users in and tracks their sessions. Your app reads and writes its own data in one of these ways:
- A Postgres client or an ORM on the server, such as Kysely, Drizzle, or Prisma. Read the user from
auth.api.getSession(), then filter queries bysession.user.id. - The Supabase Data API with Row Level Security (RLS). Supabase accepts JSON Web Tokens (JWTs) from any OpenID Connect (OIDC) provider through third-party auth. Better Auth’s JWT plugin can issue those JWTs, so your RLS policies can identify Better Auth users. In policies, read the user ID with
auth.jwt()->>'sub', becauseauth.uid()expects a UUID. Custom OIDC providers aren’t available in the Supabase CLI, so test this setup on a hosted project.
Link your tables to Better Auth users
To link your own tables to Better Auth users, reference better_auth."user"(id). The ID is text:
create table public.profiles ( id bigint generated always as identity primary key, user_id text not null references better_auth."user" (id) on delete cascade, bio text);Add tables like this to a file in supabase/schemas/. Then generate and apply a migration, as in Generate and apply the migration. If a table is in a schema that is exposed via the Data API, enable RLS on it.
Resources
- Better Auth Postgres adapter
- Better Auth CLI
- Better Auth TanStack Start integration
- Declarative database schemas
- Connect to your database