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

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

  1. Create a TanStack Start app and move into its directory:

    pnpm dlx @tanstack/cli@latest create my-app --package-manager pnpm --yes --no-intent
    cd my-app

    The --yes flag accepts the default options instead of prompting for them. The --no-intent flag skips TanStack’s optional setup for AI coding agents.

  2. Install Better Auth, the Postgres driver, Kysely, and the Supabase CLI. Better Auth uses Kysely to connect to Postgres:

    pnpm add better-auth pg kysely
    pnpm 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 init
pnpm supabase start

When the stack is running, the CLI prints its connection details. The local database URL is:

postgresql://postgres:postgres@127.0.0.1:54322/postgres

Set environment variables

  1. Generate a secret that Better Auth uses to sign session cookies:

    pnpm dlx auth@latest secret
  2. In your project root, create a .env file with the secret, the local database URL, and your app’s URL:

    DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
    BETTER_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 --yes

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" ( ... );

Generate and apply the migration

  1. 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-apply
    pnpm supabase migration up
  2. Confirm that the database matches what Better Auth expects:

    pnpm dlx auth@latest check schema

    The command prints Schema check passed against the live database.

  3. Commit supabase/schemas/better_auth.sql and 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

  1. Create src/lib/auth-client.ts with the Better Auth client:

    import { createAuthClient } from 'better-auth/react'
    export const authClient = createAuthClient()
  2. 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 })
    })
  3. Replace src/routes/index.tsx with 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>
    <button
    className={`${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>
    <input
    className={inputClass}
    placeholder="Name"
    value={name}
    onChange={(e) => setName(e.target.value)}
    />
    <input
    className={inputClass}
    placeholder="Email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    />
    <input
    className={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">
    <button
    className={`${buttonClass} bg-neutral-900 text-white hover:bg-neutral-700`}
    onClick={handleSignIn}
    >
    Sign in
    </button>
    <button
    className={`${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:

  1. Start the dev server:

    pnpm dev
  2. Open your app at http://localhost:3000, and sign up with a name, email, and password. The page shows Signed in as with your email.

  3. Check that the user is stored in the better_auth schema:

    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 the better_auth schema.

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

  1. Sign in to the Supabase CLI and link your project:

    pnpm supabase login
    pnpm supabase link
  2. Push your migrations to the hosted database. The CLI asks you to confirm before it changes your project:

    pnpm supabase db push --remote
  3. Where your app runs, set the following environment variables:

    • DATABASE_URL: the connection string from your project’s Connect panel
    • BETTER_AUTH_SECRET: a new secret for production, generated with pnpm dlx auth@latest secret
    • BETTER_AUTH_URL: your app’s public URL

Choose the DATABASE_URL connection type based on where your app runs:

Where your app runsConnection type
Serverless platforms, such as Vercel or NetlifyShared pooler, transaction mode
A long-running server on an IPv4-only networkShared pooler, session mode
A long-running server with IPv6 or the IPv4 add-onDirect 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:

  1. In src/lib/auth.ts, update the Better Auth configuration. For example, add a plugin.

  2. 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 --yes

    For example, adding the Better Auth username plugin 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");
  3. Append the changes to your schema file, and delete the temporary file:

    cat better_auth_changes.sql >> supabase/schemas/better_auth.sql
    rm better_auth_changes.sql
  4. Generate and apply a new migration:

    pnpm supabase db schema declarative sync -f add_better_auth_username --no-apply
    pnpm supabase migration up
  5. Deploy the migration with pnpm supabase db push --remote.

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 by session.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', because auth.uid() expects a UUID. Custom OIDC providers aren’t available in the Supabase CLI, so test this setup on a hosted project.

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

More Auth guides