Sign up creates the account
In this lesson a visitor fills in the sign-up form, submits it, and gets a real account in Postgres: a user row and an account row holding the hash of their password.
What they do not get is a session.
The project requires email verification, so submitting the form lands the visitor on a “check your inbox” screen with no cookie set: the account exists, but nobody is signed in until the email is verified.
The visible result is small, since the work is rows in the database rather than a new screen. The form already renders — it ships with the starter — and the verify-email screen it leads to is still a placeholder. What you build here is everything behind the submit button.
What this lesson builds
Section titled “What this lesson builds”This lesson stands up the auth spine and proves it with one working sign-up.
You configure the betterAuth instance as the single server-side auth module, consume Better Auth’s API directly with no wrapper of your own, let the CLI generate the four-table Drizzle schema, and mount one catch-all handler at /api/auth/[...all] for every endpoint the library exposes.
Wire nextCookies() into the plugin array now.
It is the bridge that lets a Better Auth call set its cookie on the Server Action’s response, and without it the cookie silently never reaches the browser.
No session is issued this lesson, but having the bridge in place means the first session lands cleanly in the next one.
requireEmailVerification: true and autoSignIn: false both hold from the start, and their combined effect is the point of the lesson: sign-up creates the account but issues no session and sets no cookie, so the user must verify their email before any session exists.
The sign-up action validates the submitted FormData, maps thrown Better Auth errors, and redirects to /verify-email?email=… on success.
A taken email is deliberately not an error: under autoSignIn: false it returns the same generic success as a fresh sign-up, so the action ships no “email already exists” branch that would tell an attacker which emails are registered.
You also write getCurrentUser() and requireUser() now, alongside the instance in lib/auth.ts.
They are part of the spine even though nothing calls them yet.
/sign-up with a fresh email, a 12-character password, and a name creates a user row whose emailVerified is false.account row with providerId: 'credential' and a scrypt hash in the password column; no verification row is written.session_token after sign-up — and the browser is redirected to /verify-email?email=…src/db/schema/auth.ts with user, session, account, and verification tables, and the migration creates all four in Postgres with their indexes and FK cascades.Coding time
Section titled “Coding time”Build the solution against the brief and the tests first, then open the walkthrough to check your work. The order below is build order — environment, schema, instance, then the handler and the action — because each step depends on the one before it.
Reference solution and walkthrough
Add the two env vars
Section titled “Add the two env vars”Every module reads its Better Auth config through the validated env object, never process.env directly. So start by adding the two new variables to the boundary: to the server block, where they are validated, and to runtimeEnv, where they are mapped from process.env.
import { createEnv } from '@t3-oss/env-nextjs';import { z } from 'zod';
// The single env boundary: application code imports `env`, never `process.env`.// createEnv validates at build time — a missing/invalid DATABASE_URL fails// `next build` with a message naming the variable.export const env = createEnv({ server: { DATABASE_URL: z.url(), DATABASE_URL_UNPOOLED: z.url(), SEED: z.coerce.number().default(1), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.url(), RESEND_API_KEY: z.string().min(1), EMAIL_FROM: z.string().min(1), EMAIL_REPLY_TO: z.email(), }, client: { NEXT_PUBLIC_APP_NAME: z.string().min(1), NEXT_PUBLIC_APP_URL: z.url(), }, runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, DATABASE_URL_UNPOOLED: process.env.DATABASE_URL_UNPOOLED, SEED: process.env.SEED, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, RESEND_API_KEY: process.env.RESEND_API_KEY, EMAIL_FROM: process.env.EMAIL_FROM, EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, },});The z.string().min(32) on the secret has a reason: a shorter secret weakens every cookie and token signature derived from it, so the boundary refuses to boot the app with a weak one.
Write the CLI-only generator config
Section titled “Write the CLI-only generator config”The Better Auth CLI writes your schema by loading a config file and reading its options to decide which tables it needs. It cannot read your real auth.ts, because that file opens with import 'server-only', a marker that throws the instant it loads outside the React Server runtime, and the CLI’s loader is not that runtime. So the project keeps a second config just for the generator: a 'server-only'-free mirror carrying only the options that shape the schema.
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '@/db';
// CLI-only generator config — the `auth:generate` target. It is a server-only-free// mirror of lib/auth.ts: the CLI's jiti loader executes this whole import graph, and// any `import 'server-only'` in it throws, so this file (and everything it reaches)// stays server-only-free. It must never import @/lib/auth, @/lib/email, or// @/lib/suppressions. The real auth instance lives in src/lib/auth.ts.//// Only the schema-shaping options are mirrored — the adapter `provider` and the// `emailAndPassword` block — which yields the byte-identical four-table schema// (user/session/account/verification); no plugin here adds tables.export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true },});Only the options that affect the table shape belong here. The provider targets the generator at Postgres; emailAndPassword is what makes it emit the account table with a password column. Session, cookie, and plugin settings leave the schema unchanged, so they are absent — and keeping them out is what keeps this file free of the 'server-only' imports they would drag in.
Generate and commit the schema
Section titled “Generate and commit the schema”The auth:generate script points the CLI at the config above and writes the result to src/db/schema/auth.ts:
-
Run
pnpm auth:generate. The CLI readsauth-schema.config.ts, sees it is targeting Drizzle on Postgres with email+password enabled, and writes the four-table schema tosrc/db/schema/auth.ts.Terminal window pnpm auth:generate -
Open the generated file and read it top to bottom —
user,session,account,verification, plus their indexes and relations. You have seen these four tables before; revisit the Better Auth schema walkthrough in the setup chapter if any column is unfamiliar. Then commit it.Terminal window git add src/db/schema/auth.ts && git commit -m "Generate Better Auth schema"
Commit the generated file, because the schema is the source of truth your migrations diff against. A teammate or CI checking out the repo must see exactly the tables you generated, not re-run a generator and hope a different Better Auth version produces the same bytes. Generate once, read it, commit it, and from then on it is ordinary code under review like any hand-written schema.
This is what the generator wrote — you type none of it; it is here so you know what you committed:
import { relations } from 'drizzle-orm';import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const user = pgTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), emailVerified: boolean('email_verified').default(false).notNull(), image: text('image'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(),});
18 collapsed lines
export const session = pgTable( 'session', { id: text('id').primaryKey(), expiresAt: timestamp('expires_at').notNull(), token: text('token').notNull().unique(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), ipAddress: text('ip_address'), userAgent: text('user_agent'), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), }, (table) => [index('session_userId_idx').on(table.userId)],);
export const account = pgTable( 'account', { id: text('id').primaryKey(), accountId: text('account_id').notNull(), providerId: text('provider_id').notNull(), userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), accessToken: text('access_token'), refreshToken: text('refresh_token'), idToken: text('id_token'), accessTokenExpiresAt: timestamp('access_token_expires_at'), refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), scope: text('scope'), password: text('password'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index('account_userId_idx').on(table.userId)],);
export const verification = pgTable( 'verification', { id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), expiresAt: timestamp('expires_at').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') .defaultNow() .$onUpdate(() => /* @__PURE__ */ new Date()) .notNull(), }, (table) => [index('verification_identifier_idx').on(table.identifier)],);
18 collapsed lines
export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account),}));
export const sessionRelations = relations(session, ({ one }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }),}));
export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id], }),}));Two columns are worth flagging because they back claims this lesson makes. account.password is where the scrypt hash of the password lands, never the plaintext, and a test checks exactly that. The verification table exists in the schema but stays empty throughout this flow: in this version of Better Auth the email-verification token is a stateless signed JWT carried in the URL, not a row that gets written and consumed. The table serves the library’s other token types; your email+password flow never touches it.
Spread the schema into the db client
Section titled “Spread the schema into the db client”The Drizzle client only knows about the tables you hand it. Right now that is the suppressions schema; add the generated auth schema so relational queries can resolve user, session, account, and verification.
import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';import * as suppressionsSchema from '@/db/schema';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// postgres-js is the driver that reaches a vanilla (Docker) Postgres; the Neon// serverless driver speaks HTTP/WebSocket only and cannot. casing lives on the// client, set once — TS property names stay camelCase, columns map to snake_case.const client = postgres(env.DATABASE_URL);
// The client holds the union of the pre-auth tables (email_suppressions) and the// CLI-generated auth tables (user/session/account/verification) so every app query// resolves its table.export const db = drizzle(client, { schema: { ...suppressionsSchema, ...authSchema }, casing: 'snake_case',});
// The pooled/unpooled split is a no-op locally; this alias exists so seed/migrate// code can read `dbUnpooled` per the convention Unit 20 makes real with Neon.export const dbUnpooled = db;Migrate the database
Section titled “Migrate the database”The schema file describes the tables; the migration creates them. Generate a named migration from the schema diff, then apply it. Drizzle reads both schema files, the suppressions one and the auth one, and emits the SQL to create the four new tables with their indexes and foreign keys.
-
Generate the migration from the schema diff, giving it a readable name:
Terminal window pnpm db:generate --name add_auth_tables -
Apply it to the running Postgres:
Terminal window pnpm db:migrate -
Open Drizzle Studio and confirm the four tables now exist alongside
email_suppressions. Check thatsessionandaccounteach carry theiruser_idindex and a foreign key back touserwithon delete cascade— deleting a user takes its sessions and accounts with it.Terminal window pnpm db:studio
Configure the auth instance
Section titled “Configure the auth instance”This is the heart of the lesson. src/lib/auth.ts is the one server-side auth module: it constructs the betterAuth instance and exposes the helpers the rest of the app reads through. Walk it in parts.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};import 'server-only' is the very first line. This module reaches the database and holds the auth secret, so it must never end up in a browser bundle — the marker turns an accidental client import into a build error instead of a silent leak.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};SESSION_COOKIE_PREFIX is declared once and exported, then read back by the proxy in the final lesson. Keeping it as one source of truth stops the set and the read from drifting; production locks it to __Host-, dev relaxes it because the browser won’t set a Secure cookie over http://localhost.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};The emailAndPassword block is the lesson’s center: requireEmailVerification: true and autoSignIn: false mean sign-up creates the account but issues no session, and minPasswordLength: 12 pairs with the schema check in the action.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};The session block carries the setup chapter’s values — a 30-day expiry, 1-day sliding renewal, a 10-minute fresh window, and a 5-minute cookie cache. You exercise none of them here, but they belong on the instance from the moment it exists.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};nextCookies() is last in plugins: the bridge that flushes Better Auth’s Set-Cookie onto the Server Action response. Out of order or absent, a call succeeds on the server but the cookie never reaches the browser — invisible this lesson because no session is issued, which is exactly why it is easy to forget.
import 'server-only';
import { betterAuth } from 'better-auth';import { drizzleAdapter } from 'better-auth/adapters/drizzle';import { nextCookies } from 'better-auth/next-js';import type { Route } from 'next';import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
import { db } from '@/db';import * as authSchema from '@/db/schema/auth';import { env } from '@/env';
// Declared once here, imported by the proxy. `__Host-` can't set over// http://localhost, so dev drops the prefix.export const SESSION_COOKIE_PREFIX = process.env.NODE_ENV === 'production' ? '__Host-better-auth' : 'better-auth';
export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg', schema: authSchema }), secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, autoSignIn: false, }, session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24, freshAge: 60 * 10, cookieCache: { enabled: true, maxAge: 5 * 60 }, }, advanced: { cookiePrefix: SESSION_COOKIE_PREFIX, useSecureCookies: process.env.NODE_ENV === 'production', }, // nextCookies() MUST be last in `plugins` — it flushes Set-Cookie from the action // response; out of order, sign-up/sign-in succeed server-side but no cookie lands. plugins: [nextCookies()],});
type User = typeof auth.$Infer.Session.user;
// The single direct session read in the codebase; `cache` dedupes it per request.// Every read flows through getCurrentUser / requireUser, never this directly.const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => (await getSession())?.user ?? null;
export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect( (next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in') as Route, ); } return user;};The read ladder: a React-cached getSession is the one direct call to auth.api.getSession, getCurrentUser() returns the user or null, and requireUser(next?) redirects to /sign-in when there is nobody. Nothing calls these until the final lesson, but they are the spine’s public read surface.
Two choices repay more detail. The __Host- prefix is a browser-enforced lock: a cookie carrying it must be Secure, host-only, and path /, and the browser won’t set a Secure cookie over http://localhost. Routing it through one exported constant matters because the proxy in the final lesson reads the cookie back by this exact prefix; let the value drift between set and read and the lookup silently misses, bouncing signed-in users.
requireUser(next?) optionally threads a ?next= query param so the user lands back where they were headed after signing in. The browser side of the spine is already provided in src/lib/auth-client.ts, a bare createAuthClient() with no baseURL because the client and the API share an origin; the client islands in later lessons call it.
Mount the catch-all handler
Section titled “Mount the catch-all handler”Better Auth exposes dozens of endpoints — sign-up, sign-in, sign-out, verify, session reads, and more — all under /api/auth, served by one route file. The [...all] segment is a catch-all that matches every path beneath it and hands the request to the library’s handler.
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth';
export const { POST, GET } = toNextJsHandler(auth);The library expects to own the entire /api/auth/* surface from this one handler; a route file per endpoint would break it. Three lines are the whole server-side route surface for auth in the project.
Write the sign-up action
Section titled “Write the sign-up action”Now the action that ties it together. It parses the form, calls Better Auth, and redirects.
'use server';
import type { Route } from 'next';import { redirect } from 'next/navigation';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { err, type Result } from '@/lib/result';
const SignUpSchema = z.strictObject({ name: z.string().min(1).max(80), email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(12),});
export const signUpAction = async ( _prevState: Result<never> | null, formData: FormData,): Promise<Result<never>> => { const parsed = SignUpSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: sign-up is a public endpoint.
const { name, email, password } = parsed.data; try { // No taken-email branch: under autoSignIn:false a duplicate returns generic // success, so enumeration is closed at the source (Ch053 L1) — a taken email // and a fresh one are indistinguishable to the caller. await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { return mapAuthError(e); }
redirect(`/verify-email?email=${encodeURIComponent(email)}` as Route);};The parse seam comes first and owns all input validation. The schema also normalizes the email — trimmed and lowercased — and its min(12) is the boundary check that pairs with minPasswordLength: 12 on the instance.
'use server';
import type { Route } from 'next';import { redirect } from 'next/navigation';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { err, type Result } from '@/lib/result';
const SignUpSchema = z.strictObject({ name: z.string().min(1).max(80), email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(12),});
export const signUpAction = async ( _prevState: Result<never> | null, formData: FormData,): Promise<Result<never>> => { const parsed = SignUpSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: sign-up is a public endpoint.
const { name, email, password } = parsed.data; try { // No taken-email branch: under autoSignIn:false a duplicate returns generic // success, so enumeration is closed at the source (Ch053 L1) — a taken email // and a fresh one are indistinguishable to the caller. await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { return mapAuthError(e); }
redirect(`/verify-email?email=${encodeURIComponent(email)}` as Route);};On a parse miss the action returns err('validation', …) carrying per-field messages flattened out of the Zod error, and the provided form renders those inline under each input — no rows written.
'use server';
import type { Route } from 'next';import { redirect } from 'next/navigation';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { err, type Result } from '@/lib/result';
const SignUpSchema = z.strictObject({ name: z.string().min(1).max(80), email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(12),});
export const signUpAction = async ( _prevState: Result<never> | null, formData: FormData,): Promise<Result<never>> => { const parsed = SignUpSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: sign-up is a public endpoint.
const { name, email, password } = parsed.data; try { // No taken-email branch: under autoSignIn:false a duplicate returns generic // success, so enumeration is closed at the source (Ch053 L1) — a taken email // and a fresh one are indistinguishable to the caller. await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { return mapAuthError(e); }
redirect(`/verify-email?email=${encodeURIComponent(email)}` as Route);};signUpEmail sits in a try/catch that routes thrown errors through mapAuthError. Note what is not here: no taken-email branch. Under autoSignIn: false a duplicate returns generic success, so the catch never sees one — and inventing a branch would rebuild the enumeration oracle.
'use server';
import type { Route } from 'next';import { redirect } from 'next/navigation';import { z } from 'zod';
import { auth } from '@/lib/auth';import { mapAuthError } from '@/lib/auth/error-mapping';import { err, type Result } from '@/lib/result';
const SignUpSchema = z.strictObject({ name: z.string().min(1).max(80), email: z.string().trim().toLowerCase().pipe(z.email()), password: z.string().min(12),});
export const signUpAction = async ( _prevState: Result<never> | null, formData: FormData,): Promise<Result<never>> => { const parsed = SignUpSchema.safeParse(Object.fromEntries(formData)); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// No authorize seam: sign-up is a public endpoint.
const { name, email, password } = parsed.data; try { // No taken-email branch: under autoSignIn:false a duplicate returns generic // success, so enumeration is closed at the source (Ch053 L1) — a taken email // and a fresh one are indistinguishable to the caller. await auth.api.signUpEmail({ body: { name, email, password } }); } catch (e) { return mapAuthError(e); }
redirect(`/verify-email?email=${encodeURIComponent(email)}` as Route);};On success, redirect to /verify-email carrying the email as a query param. There is no session to set — requireEmailVerification: true means the account exists but nobody is signed in. The redirect is the win this lesson confirms.
One detail behind the email normalization: trimming and lowercasing before validation means Ada@Acme.test and ada@acme.test resolve to the same account.
The form is already done — src/app/(auth)/sign-up/sign-up-form.tsx wires useActionState(signUpAction, null), renders the top-level userMessage in an error card, and drops a per-field error under each input. The action is the only thing you write here.
The toNextJsHandler catch-all route and the nextCookies() plugin, straight from the source.
drizzleAdapter with provider 'pg' and the schema-generation step the CLI runs.
What auth:generate does — the generate command that writes your four-table schema.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2The suite talks to the same local Postgres the app uses, so make sure Docker is up and the migration ran first. It checks four behaviors: a fresh sign-up writes an unverified user row; the same submission writes a credential account with a scrypt hash and no verification row; invalid passwords and malformed emails are rejected at the boundary with no rows created; and a second sign-up with a taken email follows the same redirect as the first, so there is no conflict tell. Expect every test to pass:
✓ tests/lessons/Lesson 2.test.ts (5 tests) ✓ sign-up creates an unverified user ✓ sign-up creates a credential account and no verification row ✓ sign-up rejects invalid input without writing rows ✓ sign-up is enumeration-safe for a taken email
Test Files 1 passed (1) Tests 5 passed (5)The tests cover the rows and the rejection paths but never open a browser or read the CLI output. Confirm the rest by hand:
/verify-email?email=… and DevTools (Application → Cookies) shows no session_token cookie — none is issued until the email is verified.pnpm auth:generate produced src/db/schema/auth.ts with all four tables, and Studio shows them in Postgres with their indexes and FK cascades; the verification table is present but empty after a sign-up.src/db/index.ts spreads authSchema into the Drizzle client.src/lib/auth.ts starts with 'server-only' on its first line.The account now exists, but the verify screen is a dead end: nothing sends the link yet. The next lesson, The email verification gate, builds that step.