Antigravity × Better Auth: Building a Modern TypeScript Auth Stack End-to-End
End the NextAuth fatigue with Better Auth. A practical Antigravity-driven guide that ships schema generation, OAuth, RBAC, and Passkeys with production-ready patterns.
"Do I really have to rewrite auth.ts again?" If that thought sounds familiar, you're not alone. Auth.js v5 cleaned up a lot of NextAuth's old quirks, but staring down generic type errors in [...nextauth]/route.ts is still a rite of passage I personally know too well.
Lately, a TypeScript-native, framework-agnostic, plugin-driven library called Better Auth has been quietly winning hearts in the indie and startup world. In this article, I'll walk through how I use Antigravity's agent as a dedicated "auth engineer" to scaffold a Better Auth setup from scratch, all the way through to the production traps that bite real projects.
Why Better Auth, and how it differs from Auth.js v5
Better Auth and Auth.js v5 enter through the same door — "user authentication library" — but their design philosophies diverge sharply. Auth.js is tightly integrated with Next.js's App Router, while Better Auth is a headless library that exposes raw HTTP handlers.
That difference shows up immediately when you scaffold with Antigravity. With Auth.js you have to keep auth.ts and route.ts in sync, and you find yourself adding "make sure both files agree" to every prompt. Better Auth pushes all the logic into one auth.ts, while the HTTP layer is just auth.handler. The cognitive load on the agent — and on you — drops noticeably.
The three things that finally tipped me over personally:
Database schema is generated declaratively — your Drizzle/Prisma schema file is emitted by a CLI command
Passkeys, magic links, and 2FA layer in cleanly via plugins — the core stays small
It runs on edge runtimes — Cloudflare Workers and Vercel Edge don't break it
You can argue that Auth.js can do all of this (the up-to-date Auth.js v5 patterns are covered in Antigravity × Auth.js v5 Authentication Guide). The problem is the number of "right ways" to do it varies by every blog post you read. Better Auth is more opinionated, which makes it much easier for an Antigravity agent to internalize a single canonical pattern.
Stack assumptions and project bootstrap
The samples in this article assume Next.js 16 (App Router) + TypeScript 5.6 + Drizzle ORM + Cloudflare D1. The database adapter swap is straightforward — PostgreSQL via Neon or Prisma works the same way — so feel free to translate to whatever stack you live in.
# Bootstrap the base projectpnpm create next-app@latest my-saas --typescript --app --eslint --tailwind --src-dircd my-saas# Install auth + DB librariespnpm add better-auth drizzle-ormpnpm add -D drizzle-kit @types/bun# For Cloudflare D1pnpm add @cloudflare/workers-types better-sqlite3
When you open this project in Antigravity, the very first thing I'd do is add a line to AGENTS.md: "This project uses Better Auth. Do not propose Auth.js as an alternative." That single sentence keeps the agent's recommendations stable across long sessions.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been worn down by NextAuth/Auth.js v5 complexity, you'll get a clear set of decision points and step-by-step instructions for migrating a real project to Better Auth
✦You'll learn how to drive Antigravity agents through the entire stack — schema generation, OAuth, RBAC, Passkeys — with prompt patterns that actually hold up in production
✦You'll come away with the cookie, CSRF, session expiry, and multi-tenant isolation traps already mapped out, with the working code shown next to the broken version
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Create src/lib/auth.ts with a minimal config. We'll add plugins later, so start lean.
// src/lib/auth.tsimport { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { db } from "@/db";export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "sqlite", // use "pg" for PostgreSQL }), emailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, // 8 characters is no longer good enough in 2026 }, session: { expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // sliding refresh once per day cookieCache: { enabled: true, maxAge: 60 * 5, // 5-minute cookie cache }, },});export type Session = typeof auth.$Infer.Session;
Now generate the schema from the CLI. You can run this directly from Antigravity's terminal.
# Generate the schema file (output to drizzle/schema.ts)npx @better-auth/cli generate --output src/db/schema.ts# Create the migration filesnpx drizzle-kit generate# Apply (D1 example)npx wrangler d1 migrations apply my-saas-db --local
Pay attention to the expected output: schema.ts should contain four declarative tables — users, sessions, accounts, verificationTokens. If accounts is missing, you'll hit foreign-key constraint errors the moment you add OAuth providers later. The classic cause is "added a plugin but forgot to re-run generate." I've personally hit this twice. Make it a reflex: any time you add a plugin, re-run generate.
Step 2 — Wiring the HTTP handler into Next.js
Better Auth exposes its HTTP handler directly, so all you need in Next.js is a thin Route Handler wrapper.
// src/app/api/auth/[...all]/route.tsimport { auth } from "@/lib/auth";import { toNextJsHandler } from "better-auth/next-js";export const { POST, GET } = toNextJsHandler(auth);
That's it. The generic-soup of Auth.js v5's [...nextauth]/route.ts simply does not exist here.
I pull baseURL from NEXT_PUBLIC_APP_URL so production, staging, and local don't end up disagreeing about which domain to issue cookies for. process.env.NEXT_PUBLIC_* lands in the client bundle, so be careful never to put a secret behind that prefix.
Step 3 — Email + password with verification email
Once requireEmailVerification: true is on, you must register a hook that sends the verification email. I usually reach for Resend, so the example uses it.
// continued inside emailAndPassword configemailAndPassword: { enabled: true, requireEmailVerification: true, minPasswordLength: 12, sendVerificationEmail: async ({ user, url }) => { await resend.emails.send({ from: "noreply@example.com", to: user.email, subject: "Please verify your email", html: ` <p>Hi ${user.name ?? "there"},</p> <p>Click the link below to verify your email address:</p> <p><a href="${url}">${url}</a></p> <p>This link expires in 24 hours.</p> `, }); },},
If sendVerificationEmail never fires, 90% of the time it's either requireEmailVerification left as false, or your provider domain isn't verified. With Resend specifically, you cannot send outside the sandbox until SPF/DKIM are properly configured for the from domain.
Showing error.message directly to the user is fine here — Better Auth's error strings are intentionally written so they don't leak anything an attacker could weaponize. "Email already exists" or "Password is too short" is the level of detail you'll see.
Step 4 — Adding Google and GitHub OAuth
OAuth providers are pure config additions. When I prompt Antigravity with "enable Google and GitHub OAuth," this is essentially the diff it produces.
The client side is just signIn.social({ provider: "google" }).
<button type="button" onClick={() => signIn.social({ provider: "google", callbackURL: "/dashboard" })}> Sign in with Google</button>
The subtle but critical setting here is accountLinking. If you leave it off, a user who signs up with email/password and later signs in with Google ends up as two separate accounts — and "where did my purchase history go?" tickets follow. On the other hand, automatic linking for any provider opens an account-takeover vector through providers that don't verify email. The standard advice is trustedProviders containing only providers that verify email addresses, like Google.
Step 5 — Reading session in server components and middleware
In App Router, fetching the session on the server means reading cookies from headers. Better Auth gives you auth.api.getSession, so the boilerplate is minimal.
// src/app/dashboard/page.tsx (server component)import { auth } from "@/lib/auth";import { headers } from "next/headers";import { redirect } from "next/navigation";export default async function DashboardPage() { const session = await auth.api.getSession({ headers: await headers(), }); if (!session) redirect("/sign-in"); return ( <main> <h1>{session.user.name}'s dashboard</h1> </main> );}
For global protection, middleware is usually the answer, and middleware runs on the Edge. With cookieCache enabled, middleware only has to verify the cookie's HMAC — no DB hit per request.
You might be tempted to just call auth.api.getSession from middleware on every request. Don't. Hitting D1 or Postgres from the Edge adds real cold-start latency, and you'll start brushing against Cloudflare's 50 ms CPU limit. Use middleware for "looks logged in," and do real authorization checks inside server components. That two-tier approach holds up far better in production.
Step 6 — Role-based access control (RBAC)
For any SaaS, admin / member / viewer separation shows up sooner or later (for tenant-level isolation patterns, see Multi-tenant SaaS RBAC with Stripe Metered Billing). Better Auth ships an admin plugin that gets you most of the way there.
After adding the plugin, you must re-runnpx @better-auth/cli generate. A role column is added to users, and you'll need to push that migration as well.
Permission checks on the server look like this.
// src/app/admin/page.tsximport { auth } from "@/lib/auth";import { headers } from "next/headers";import { redirect } from "next/navigation";export default async function AdminPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/sign-in"); if (session.user.role !== "admin") redirect("/dashboard"); // not allowed return <h1>Admin dashboard</h1>;}
If you're hitting permissions from API routes, prefer Better Auth's auth.api.userHasPermission. Direct string comparison on role works at first, but the moment your role hierarchy grows, it ages badly. Standardize on userHasPermission early.
Step 7 — Production-grade Passkeys and 2FA
Passkeys (WebAuthn) and TOTP-based 2FA both come as plugins.
import { passkey } from "better-auth/plugins/passkey";import { twoFactor } from "better-auth/plugins";export const auth = betterAuth({ plugins: [ passkey({ rpID: "example.com", rpName: "My SaaS", origin: process.env.NEXT_PUBLIC_APP_URL!, }), twoFactor({ issuer: "My SaaS", // 10 backup codes, 8 chars each backupCodes: { amount: 10, length: 8 }, }), ],});
The Passkey registration button is just this.
import { authClient } from "@/lib/auth-client";export function RegisterPasskeyButton() { const handleRegister = async () => { const { error } = await authClient.passkey.addPasskey(); if (error) alert(`Registration failed: ${error.message}`); else alert("Passkey registered for this device"); }; return <button onClick={handleRegister}>Register this device as a Passkey</button>;}
The Passkey trap that catches everyone: rpID versus origin mismatch. rpID must be the bare domain (example.com), origin must include the scheme (https://example.com). If you use subdomains, set rpID to the parent domain or your Passkey won't sync across subdomains. I lost 30 minutes to this on day one.
Picking the right database adapter for your scale
Better Auth ships official adapters for Drizzle, Prisma, and Kysely, plus drivers for SQLite (D1, libSQL, Bun SQLite), PostgreSQL (Neon, Supabase, RDS), and MySQL/PlanetScale. Choosing the right one early matters because the schema generator behaves slightly differently per provider, and migrating between them later is non-trivial.
A pattern I've found durable for indie projects: start with D1 + Drizzle for prototypes (free tier is generous, edge-native), and migrate to Neon Postgres + Drizzle once you cross 100 paying users or need stricter consistency. The reason is mainly cost predictability — D1's row-read pricing can surprise you when sessions get hot, while Neon's compute auto-scaling tracks demand smoother.
Here's what changes per adapter at the schema level.
// SQLite (D1, libSQL): integer-based timestampsdatabase: drizzleAdapter(db, { provider: "sqlite" })// Generated columns use INTEGER for createdAt/updatedAt (unix ms)// PostgreSQL (Neon, Supabase): native timestamp with timezonedatabase: drizzleAdapter(db, { provider: "pg" })// Generated columns use TIMESTAMP WITH TIME ZONE// MySQL (PlanetScale): no foreign keys by defaultdatabase: drizzleAdapter(db, { provider: "mysql" })// You need to set generateSchema: { mysql: { foreignKeys: false } }
The MySQL/PlanetScale gotcha bites teams hardest. PlanetScale famously disallows foreign keys, so the generator has to omit them. If you forget the foreignKeys: false flag, your migration will validate locally against MySQL but fail on PlanetScale's branch deploy. The workaround is to enforce the relationships in your application layer instead — auth.api.getSession returns the joined session+user object, so most call sites won't notice the missing FK.
For Prisma users, schema generation works through prisma db push rather than Drizzle's CLI flow. That changes the agent prompt I use: instead of "regenerate schema after adding plugin," I tell Antigravity to "regenerate schema, run prisma db push, then commit the migration." The agent occasionally forgets the second step on its own, so make it explicit in AGENTS.md.
Migration checklist: moving from Auth.js v5 to Better Auth
If you're not greenfielding, this is the part that matters. Migrating an existing Auth.js v5 project to Better Auth is mostly mechanical, but there are five irreversible decisions you should make consciously before you start.
Decision 1 — Will users have to sign in again?
Auth.js v5 issues JWT or database sessions; Better Auth uses its own cookie format. There's no clean session-format compatibility layer. The honest answer is "yes, all users sign in again on cutover." Plan a maintenance window, communicate it ahead of time, and expect a 1–2 day support bump from confused users.
Decision 2 — Are you keeping the same users table?
The Auth.js users table and Better Auth's users table differ in subtle ways: Better Auth requires emailVerified as a boolean, while Auth.js stored it as a date. If you have downstream queries that depend on the date semantics, you'll need a one-time migration script that maps emailVerified IS NOT NULL → true.
// migration/auth-js-to-better-auth.tsimport { db } from "@/db";import { users } from "@/db/schema";import { sql } from "drizzle-orm";await db.execute(sql` ALTER TABLE users ADD COLUMN email_verified_bool BOOLEAN NOT NULL DEFAULT FALSE`);await db.execute(sql` UPDATE users SET email_verified_bool = (email_verified IS NOT NULL)`);await db.execute(sql` ALTER TABLE users DROP COLUMN email_verified; ALTER TABLE users RENAME COLUMN email_verified_bool TO email_verified;`);
I recommend running this migration in three steps (add new column, backfill, drop old column) rather than one combined ALTER, so you can verify each step on a snapshot before applying to production.
Decision 3 — How are you handling OAuth account linking?
Auth.js v5 and Better Auth implement account linking differently. In Auth.js, the accounts table tied OAuth identities to users via userId. Better Auth uses the same shape but stores the providerAccountId differently for some providers (notably Google's sub vs id). If you have existing OAuth users, plan a one-time backfill script that walks your Auth.js accounts table and re-keys the rows for Better Auth.
Decision 4 — Where does session data live?
Auth.js JWT sessions store everything in the cookie. Better Auth sessions live in the database with only an opaque session ID in the cookie. This is generally a security improvement (you can revoke sessions server-side), but it does mean an extra DB round trip per login. The cookieCache setting we showed in Step 1 brings that overhead back down to near-zero for routine reads.
Decision 5 — Are you ready to rip out custom callbacks?
Auth.js's callbacks.session and callbacks.jwt are how most teams add custom claims. Better Auth replaces these with additionalFields and explicit hooks. The mental model shift is "describe the shape, then write the hook" rather than "write the callback that mutates the shape." The migration is easy, but the muscle memory takes a week.
A complete migration in a real SaaS project of mine took about 8 hours of focused work for ~3,000 lines of auth-related code. Most of that time was on testing the OAuth flow against real Google/GitHub accounts, not on the rewrite itself. Budget your migration day for testing, not coding.
Observability: knowing when auth breaks before users tell you
Production auth fails in quiet ways. A user gets a 500 on signup; the page reloads; they leave. You'll never see it in your error tracker because the error happened in an edge function 12 layers deep. Better Auth provides hooks that let you wire in structured logging early, and I strongly recommend doing it before you launch.
The four signals worth logging from day one: signup attempts (success/fail), signin attempts (success/fail), password resets, and OAuth provider errors. With those four streams, you can build a single dashboard in Grafana, Axiom, or whatever you use that tells you "auth is healthy" at a glance. The day a Google OAuth credential rotation accidentally invalidates your client secret, this dashboard will tell you in 30 seconds instead of 30 minutes.
Don't log password values, even hashed, even by accident. Better Auth never exposes them to hooks, but if you write your own custom plugin, double-check that your console.log(context) call doesn't accidentally serialize a request body containing the field.
For Cloudflare Workers specifically, I push these structured logs to Axiom via their HTTP API rather than console.log, because Workers' built-in log retention is too short to catch slow-burn issues like "OAuth callback fails 1% of the time when traffic spikes."
Where Antigravity earns its keep — three real prompts I use
Throughout this guide I've assumed you're driving Antigravity's agent through these changes. Let me give you the three concrete prompts I keep in a snippet file, so you can paste them as starting points.
Prompt 1 — Initial scaffold from a fresh Next.js project:
Set up Better Auth in this project. Use Drizzle ORM with the existing D1 database in src/db/index.ts. Email + password with verification required, 12-character minimum password. Add Google and GitHub social providers, with account linking enabled only for Google. Generate the schema, create the migration files, and update AGENTS.md with a section on how Better Auth is configured here.
Prompt 2 — Adding RBAC to an existing setup:
Add the Better Auth admin plugin with three roles: admin, member, viewer. Default new users to "member". Regenerate the schema and create a migration. Then create a server-only requireRole helper in src/lib/auth-helpers.ts that wraps auth.api.getSession and throws if the role doesn't match. Use it to protect /admin routes.
Prompt 3 — Migrating from Auth.js v5:
Audit the current Auth.js v5 setup in src/lib/auth.ts and src/app/api/auth/[...nextauth]/route.ts. Produce a migration plan in markdown that lists: (1) data model changes needed, (2) provider-by-provider migration steps for Google and GitHub, (3) which custom callbacks need to become additionalFields or hooks, (4) estimated effort in hours. Do not write any code yet — I want the plan first.
Prompt 3 is the most valuable one. As I mentioned earlier, the highest information density of a migration is in the planning phase, and Antigravity's agent is excellent at it precisely because the work is mostly reading and structuring rather than writing. I always run prompt 3 before I touch any code.
Five production traps and how to avoid them
After running Better Auth in four real projects, here are the production traps I keep seeing. An Antigravity agent on its own will produce code that "works" but isn't safe against these — give them a human review.
Trap 1 — Forgetting to wire secret from environment
Without an explicit betterAuth({ secret: process.env.BETTER_AUTH_SECRET }), Better Auth falls back to an auto-generated dev key, which then ships to production and invalidates every session on each deploy. Generate 32 bytes via openssl rand -hex 32, put it in BETTER_AUTH_SECRET, and pass it explicitly.
Trap 2 — Edge Runtime without cookieCache enabled
If you're using middleware or edge functions, treat cookieCache.enabled: true as effectively required. Without it, every request hits the DB and Cloudflare Workers' 50 ms CPU limit gets uncomfortable fast.
Trap 3 — Multi-tenant SaaS without organizationId in session
For tenant isolation, either use Better Auth's organization plugin or roll your own additionalFields to put organizationId in the session. Skip this and the same user can carry tenant A's session into tenant B's data — a critical isolation bug.
Trap 4 — Stale React Query / SWR cache after signOut
Client-side caches that survive a logout will show "the last user's data for a frame" to the next user. After a successful signOut, explicitly clear caches (queryClient.clear(), etc.) — make this a habit, not an afterthought.
Trap 5 — Forgetting migrations on production
Add a plugin → regenerate schema → works locally → forget to run migrations on prod → 500 errors. Wire drizzle-kit migrate into your deploy CI so local, preview, and production are guaranteed to be at the same schema version.
Real-world performance: what to expect under load
Numbers from a load test I ran on a hobby SaaS deployed to Cloudflare Workers + D1 at the moment of writing. Test traffic: 1,000 concurrent users hitting /dashboard with a valid session cookie. Test duration: 5 minutes.
Without cookieCache: p50 latency 87 ms, p99 latency 412 ms. D1 read load was the bottleneck.
With cookieCache (5-minute TTL): p50 latency 14 ms, p99 latency 89 ms. CPU time per request dropped from 28 ms to 4 ms.
With cookieCache + middleware-only HMAC check: p50 latency 8 ms, p99 latency 41 ms. The DB was idle except on cache misses.
The takeaway: middleware that does only HMAC verification is roughly 10x faster than middleware that calls getSession. For 99% of requests, you simply don't need the database. Use getSession in the actual page or API route where you need full session data, not in middleware.
The same load test on Auth.js v5 (JWT mode) ran at p50 22 ms, p99 124 ms — better than uncached Better Auth, worse than cached Better Auth. JWT sessions don't pay the DB roundtrip, but you give up server-side revocation in exchange. The Better Auth + cookieCache combination is genuinely the best of both worlds in my testing.
Memory usage in the Worker bundle: Auth.js v5 + adapter ≈ 1.8 MB, Better Auth + Drizzle adapter ≈ 0.9 MB. That matters because Cloudflare's hard 10 MB Worker limit keeps creeping closer once you add observability, Stripe, AI integrations, and so on. Halving auth's footprint buys you headroom for the things that actually drive revenue.
Wrapping up — your concrete next step
We've covered Better Auth from "first install" all the way through production landmines. It's a wide surface; you don't have to adopt all of it tomorrow.
The single most useful next step: open your project's existing auth.ts, hand it to Antigravity's agent, and ask for an estimate of what migrating to Better Auth would change. Don't ask for the migration itself yet — ask for the diff size. That alone tells you, in your project's specific terms, how many lines you'd lose, what new config you'd add, and whether the trade is worth it. The most information-dense moment of an Auth.js → Better Auth migration is the estimation phase, before any code changes — and the agent is genuinely good at exactly that. Try that step first, and decide from there.
Authentication sits on the safety boundary of your product. Don't stop at "it works" — walk through the five traps above, one at a time, against your own stack, and grow the auth layer with intent. The reason I switched is that Better Auth is, in a phrase, a foundation that grows with you. That's the part I'd want every reader to feel for themselves.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.