Back to recipes

Better Auth Setup

Add user authentication using Better Auth with Drizzle ORM and Neon Postgres. Base setup with email/password authentication.

Setup Instructions

Agent Skill

Install the Better Auth skill for best practices:

bash
bunx skills add better-auth/skills -s better-auth-best-practices -a opencode -a cursor -y

MCP Server

Add the Better Auth MCP server for accurate API guidance:

bash
bunx add-mcp https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp -y

This updates all detected agents automatically. If no agents are detected, add -a opencode -a cursor to the command or prompt the user to specify what agents they want to use for this project.


Step 1: Install the package

bash
bun add better-auth

Step 2: Add environment variables

Add the secret to your .env.development (synced to Vercel):

env
BETTER_AUTH_SECRET="your-random-secret-at-least-32-chars"

Generate a secret using:

bash
openssl rand -base64 32

Add the URL to your .env.local (local override):

env
BETTER_AUTH_URL="http://localhost:3000"

The BETTER_AUTH_URL differs between local (http://localhost:3000) and deployed environments, so it belongs in .env.local. On Vercel, set BETTER_AUTH_URL to your production URL in the dashboard.

Step 3: Create the auth config

Create the auth config following the Environment Variable Management pattern:

ts
import { loadConfig } from "../common/load-config";

export const authConfig = loadConfig({
  server: {
    secret: process.env.BETTER_AUTH_SECRET,
    url: process.env.BETTER_AUTH_URL,
  },
});

Step 4: Update the db generate script

Create a scripts/tests/generate-schema.ts script to generate the Better Auth schema before running Drizzle migrations:

ts
import { $ } from "bun";
import { loadEnvConfig } from "@next/env";

loadEnvConfig(process.cwd());

await $`bunx @better-auth/cli@latest generate --config src/lib/auth/server.tsx --output src/lib/auth/schema.ts`;

await $`drizzle-kit generate`;

The Better Auth CLI generates schema.ts from your server config. Running it before drizzle-kit generate ensures your auth schema is always in sync when creating Drizzle migrations.

Replace the package.json db:generate script with this one.

json
"scripts": {
  "db:generate": "bun run scripts/tests/generate-schema.ts",
  "db:migrate": "drizzle-kit migrate",
  "db:studio": "drizzle-kit studio"
}

Note: This script is needed (vs. just running better-auth generate &&drizzle-kit generate) because the better-auth CLI doesn't load .env.development and .env.local files automatically. We use loadEnvConfig to load them manually. See Environment Variable Management for the full setup.

See Neon + Drizzle Setup for the initial script setup and package.json scripts.

Step 5: Create the auth server instance

Create the auth server with basic email/password authentication:

Note: We use .tsx instead of .ts to support JSX email templates when you add Better Auth Emails later.

tsx
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/client";
import { authConfig } from "./config";

export const auth = betterAuth({
  secret: authConfig.server.secret,
  baseURL: authConfig.server.url,
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
  }),
  emailAndPassword: {
    enabled: true,
  },
});

Step 6: Create the API route handler

Create the catch-all route handler for auth:

ts
import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

Step 7: Create the auth client

Create the client-side auth hooks:

ts
"use client";

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

export const { signIn, signUp, signOut, useSession } = authClient;

Step 8: Generate and run migrations

bash
bun run db:generate
bun run db:migrate

Usage

Sign Up

typescript
import { signUp } from "@/lib/auth/client";

await signUp.email({
  email: "user@example.com",
  password: "securepassword",
  name: "John Doe",
});

Sign In

typescript
import { signIn } from "@/lib/auth/client";

await signIn.email({
  email: "user@example.com",
  password: "securepassword",
});

Sign Out

typescript
import { signOut } from "@/lib/auth/client";

await signOut();

Get Session (Client)

tsx
"use client";

import { useSession } from "@/lib/auth/client";

export function UserProfile() {
  const { data: session, isPending } = useSession();

  if (isPending) return <div>Loading...</div>;
  if (!session) return <div>Not signed in</div>;

  return <div>Hello, {session.user.name}</div>;
}

Get Session (Server)

typescript
import { auth } from "@/lib/auth/server";
import { headers } from "next/headers";

export default async function Page() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return <div>Not signed in</div>;
  }

  return <div>Hello, {session.user.name}</div>;
}

Protected Page Pattern

tsx
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";

export default async function ProtectedPage() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    redirect("/sign-in");
  }

  return <div>Welcome, {session.user.name}</div>;
}

File Structure

src/lib/auth/
  config.ts    # Environment validation
  schema.ts    # Auto-generated by Better Auth CLI
  server.tsx   # Better Auth server instance (.tsx for email template support)
  client.ts    # React client hooks

src/app/api/auth/
  [...all]/route.ts  # API route handler

Adding Social Providers

To add OAuth providers like GitHub, Google, or Vercel, first add them as fields in your auth config:

ts
import { loadConfig } from "../common/load-config";

export const authConfig = loadConfig({
  server: {
    secret: process.env.BETTER_AUTH_SECRET,
    url: process.env.BETTER_AUTH_URL,
    vercelClientId: { value: process.env.VERCEL_CLIENT_ID, optional: true },
    vercelClientSecret: {
      value: process.env.VERCEL_CLIENT_SECRET,
      optional: true,
    },
  },
});

Then configure them in the server:

tsx
export const auth = betterAuth({
  // ...existing config
  socialProviders: {
    ...(authConfig.server.vercelClientId &&
      authConfig.server.vercelClientSecret && {
        vercel: {
          clientId: authConfig.server.vercelClientId,
          clientSecret: authConfig.server.vercelClientSecret,
        },
      }),
  },
});

Here we're doing it conditionally and treat Vercel Sign In as an optional feature.

Then use on the client:

typescript
await signIn.social({ provider: "vercel", callbackURL: "/chats" });

References