If you've shipped a Next.js app on NextAuth (now Auth.js), you know it works. The reason people move to Better Auth usually isn't that NextAuth is bad — it's that Better Auth gives you typed, first-class access to sessions, organizations, and database-backed concepts without bolting adapters and callbacks together by hand. The session calls are typed end to end, the schema is generated for you, and features like multi-tenancy or admin tooling are first-party rather than community glue.
This post is the honest version of that migration: the mechanical parts you can rewrite almost blindly, and the parts you still have to do by hand. I'll show real before/after code for each.
Coming from Clerk instead? See the companion guide: Moving off Clerk to Better Auth.
The mental model: NextAuth is a library, so a lot of the rewrite is mechanical
Much of a NextAuth integration is call sites — getServerSession here, useSession there, a signIn button. Those follow patterns, which means they're transformable. The part that isn't mechanical is your configuration: providers, the database adapter, and callbacks. That's the logic only you understand, and no tool should silently rewrite it.
Keep that split in your head for the whole migration:
- Call sites → mechanical, fast, low-risk.
- Config + schema + data → manual, deliberate, where the real work lives.
Step 1: Imports
NextAuth spreads its surface across several entry points. Better Auth centralizes server access in your auth instance and client access in an authClient.
// before
import { getServerSession } from "next-auth";
import { useSession, signIn, signOut } from "next-auth/react";
// after
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { authClient } from "@/lib/auth-client";
The next-auth and next-auth/react imports collapse into three: your server auth instance, next/headers (you'll need it for session reads), and the authClient.
Step 2: Server-side session reads
This is the most common change in a real codebase. NextAuth's getServerSession(authOptions) becomes a call on the Better Auth instance that takes the request headers explicitly.
// before
const session = await getServerSession(authOptions);
// after
const session = await auth.api.getSession({
headers: await headers(),
});
Passing headers() is not boilerplate you can skip — Better Auth reads the session cookie from those headers, so every server-side session read needs them.
Step 3: Client-side session reads
// before
const { data: session } = useSession();
// after
const { data: session } = authClient.useSession();
Same shape, different origin — the hook now comes off authClient instead of a top-level next-auth/react import.
Step 4: signIn / signOut (watch the arguments)
This is the first place where a find-and-replace is not enough, and it's worth slowing down. The import source changes cleanly:
// before
import { signIn, signOut } from "next-auth/react";
signIn("credentials", { email, password });
signIn("github");
// after
import { authClient } from "@/lib/auth-client";
// the call shape is different — see below
Better Auth doesn't use a single signIn(provider, options) function. Credentials and social are distinct methods with their own argument shapes:
// email + password
await authClient.signIn.email({ email, password });
// social provider
await authClient.signIn.social({ provider: "github" });
The signIn/signOut reference can be rewritten automatically, but the arguments cannot — ("credentials", { ... }) and .email({ ... }) are genuinely different APIs. Expect to revisit every sign-in call by hand.
Step 5: Environment variable renames
The names change, and there are enough of them to be error-prone. Here's the full mapping:
| NextAuth | Better Auth |
|---|---|
NEXTAUTH_SECRET |
BETTER_AUTH_SECRET |
NEXTAUTH_URL |
BETTER_AUTH_URL |
GITHUB_ID |
GITHUB_CLIENT_ID |
GITHUB_SECRET |
GITHUB_CLIENT_SECRET |
GOOGLE_ID |
GOOGLE_CLIENT_ID |
GOOGLE_SECRET |
GOOGLE_CLIENT_SECRET |
One thing to be precise about: there are two places these names live — in code (process.env.NEXTAUTH_SECRET) and in your .env file. Renaming the code references is mechanical. Renaming the actual .env entries is something you do yourself, because nothing should be reaching into your secrets file and editing it.
Step 6: The API route
NextAuth mounts its handler at [...nextauth]. Better Auth uses a catch-all [...all] route wired to its handler:
// before — app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
// after — app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
This is a file move plus a rewrite, not an in-place edit — the directory name itself changes from [...nextauth] to [...all].
Step 7: Middleware → cookie check + server requireSession
NextAuth's withAuth middleware has no direct equivalent in Better Auth, and that's deliberate. The recommended pattern is two layers:
- A lightweight cookie check in middleware/proxy for cheap edge-level redirects.
- An authoritative server-side
requireSession()in the protected route or layout, which actually validates the session.
// before — middleware.ts
export { default } from "next-auth/middleware";
// after — a cookie presence check at the edge, plus:
const session = await requireSession(); // authoritative, server-side
The edge check is fast but only checks for a cookie's presence; the server check is the one you trust. Don't collapse them into one — the whole point is that the cheap check doesn't have authority and the authoritative check isn't on the hot edge path.
The config, the schema, the data — what you still do by hand
Everything above is the boring, transformable part. Here's the part that isn't — and it's the part that actually decides whether the migration goes well.
The auth config itself. Your authOptions / NextAuth({}) body doesn't translate one-to-one. You port it deliberately:
-
providers: [...]→socialProviders: { ... } - your adapter → the matching Better Auth adapter (e.g.
drizzleAdapter) -
callbacks: { ... }→databaseHooksor plugin options - email/password, verification, and reset are explicit features you enable, not implicit defaults
// the shape you're porting *to* (illustrative)
export const auth = betterAuth({
database: drizzleAdapter(db, { /* ... */ }),
emailAndPassword: { enabled: true },
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
});
The database schema. NextAuth's tables are not Better Auth's tables. You generate the Better Auth schema and migrate:
pnpm auth:generate # produce the Better Auth schema
pnpm db:migrate # apply it
Existing user rows. This is the awkward one. Better Auth won't move your users for you — you map columns from the old tables to the new ones yourself. Password hashes only carry over if the hashing scheme matches; if it doesn't, those users need a password reset. Plan a real data-migration task here, not a script you run once and hope.
A sane order to do this in
-
git commitorstashfirst — you want a clean diff to review. - Rewrite the call sites (imports,
getServerSession,useSession, sign-in references). - Port the config, generate the schema, run the migration.
- Fix the sign-in/sign-out arguments by hand.
- Move the API route to
[...all]. - Rebuild middleware as cookie-check +
requireSession. - Rename
.enventries. - Verify: run your typecheck, then
pnpm auth:generate && pnpm db:migrate, thenpnpm devand click through the actual flows.
One thing to expect: even though the call-site rewrites are the most repetitive change, most of your time will go into the config, schema, and data — that's normal, and that's where to be careful.
Disclosure: I build a starter that automates the mechanical part
I make Ship Kit, a commercial Better Auth starter for Next.js 16 + TypeScript. (It's an independent, unofficial project — not affiliated with or endorsed by Better Auth.) I sell it, so treat this section as the ad it is.
Those migration codemods are free and open source — authlayerdev/ship-kit-migrate (MIT), deterministic and non-AI. Clone the repo and run the NextAuth transform against your project, dry-run first:
# preview every change — writes nothing
node ./migrate/cli.mjs --transform nextauth --dry ./src
# apply it (rewrites files in place)
node ./migrate/cli.mjs --transform nextauth ./src
It's idempotent (running it twice is safe), but commit or stash first regardless.
What it does: rewrites the imports, turns getServerSession(authOptions) into auth.api.getSession({ headers: await headers() }), swaps useSession() for authClient.useSession(), repoints signIn/signOut references (leaving a // TODO(ship-kit): marker because the arguments differ), renames the in-code env references, and annotates your config block with a TODO.
What it explicitly does not do — and leaves marked so you can find the remainder with one grep — is the genuinely manual work:
grep -rn "TODO(ship-kit)" . # the manual checklist the codemod left you
…that remainder being: porting the config body, the sign-in argument shapes, the DB schema, the user-row migration, the API route move, the .env file itself, and middleware. In other words, exactly the "by hand" section above. It's an assist for the mechanical part, not a one-click migrator — the parts that need judgment stay yours.
For full context: Ship Kit is one-time ($179 solo / $499 agency, lifetime updates, 14-day refund, crypto −5%), built by one developer in Berlin. It's brand new, so there are no buyer testimonials yet. More established starters like Makerkit (from $349) and supastarter (from €349) have more templates and maturity; Ship Kit's narrower distinctions are the in-product migration codemods (most rivals are docs-only) and crypto checkout. It also runs a nightly canary CI job that bumps Better Auth to the latest version and re-runs the test suite — that's a signal it tracks upstream, not a guarantee that any given upgrade will be painless.
If you'd rather not hand-write the repetitive call-site rewrites, the codemod handles that part. The config, schema, and data migration are still yours to do.
Disclosure: I'm the author of Ship Kit, a commercial product mentioned above, and I earn revenue from its sales.
Top comments (0)