{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiafqyeebtflryidc67r63sybqwgbbvbnft7v5szlr3jwbbf7ur2gm",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3motkefwmoix2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreiaxaxxqnnz2w57k4rdpzbypasrnhw7jvjxurt6g7ys2vlt2g3rlei"
    },
    "mimeType": "image/webp",
    "size": 73716
  },
  "path": "/authlayerdev/migrating-from-nextauth-to-better-auth-in-nextjs-and-what-the-boring-parts-actually-are-4lf3",
  "publishedAt": "2026-06-21T22:45:55.000Z",
  "site": "https://dev.to",
  "tags": [
    "nextjs",
    "betterauth",
    "nextauth",
    "authentication",
    "Moving off Clerk to Better Auth",
    "Ship Kit",
    "authlayerdev/ship-kit-migrate"
  ],
  "textContent": "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.\n\nThis 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.\n\n_Coming from Clerk instead? See the companion guide: Moving off Clerk to Better Auth._\n\n##  The mental model: NextAuth is a library, so a lot of the rewrite is mechanical\n\nMuch 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.\n\nKeep that split in your head for the whole migration:\n\n  * **Call sites** → mechanical, fast, low-risk.\n  * **Config + schema + data** → manual, deliberate, where the real work lives.\n\n\n\n##  Step 1: Imports\n\nNextAuth spreads its surface across several entry points. Better Auth centralizes server access in your `auth` instance and client access in an `authClient`.\n\n\n\n    // before\n    import { getServerSession } from \"next-auth\";\n    import { useSession, signIn, signOut } from \"next-auth/react\";\n\n    // after\n    import { auth } from \"@/lib/auth\";\n    import { headers } from \"next/headers\";\n    import { authClient } from \"@/lib/auth-client\";\n\n\nThe `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`.\n\n##  Step 2: Server-side session reads\n\nThis 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.\n\n\n\n    // before\n    const session = await getServerSession(authOptions);\n\n    // after\n    const session = await auth.api.getSession({\n      headers: await headers(),\n    });\n\n\nPassing `headers()` is not boilerplate you can skip — Better Auth reads the session cookie from those headers, so every server-side session read needs them.\n\n##  Step 3: Client-side session reads\n\n\n    // before\n    const { data: session } = useSession();\n\n    // after\n    const { data: session } = authClient.useSession();\n\n\nSame shape, different origin — the hook now comes off `authClient` instead of a top-level `next-auth/react` import.\n\n##  Step 4: signIn / signOut (watch the arguments)\n\nThis is the first place where a find-and-replace is _not_ enough, and it's worth slowing down. The import source changes cleanly:\n\n\n\n    // before\n    import { signIn, signOut } from \"next-auth/react\";\n    signIn(\"credentials\", { email, password });\n    signIn(\"github\");\n\n    // after\n    import { authClient } from \"@/lib/auth-client\";\n    // the call shape is different — see below\n\n\nBetter Auth doesn't use a single `signIn(provider, options)` function. Credentials and social are distinct methods with their own argument shapes:\n\n\n\n    // email + password\n    await authClient.signIn.email({ email, password });\n\n    // social provider\n    await authClient.signIn.social({ provider: \"github\" });\n\n\nThe `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.\n\n##  Step 5: Environment variable renames\n\nThe names change, and there are enough of them to be error-prone. Here's the full mapping:\n\nNextAuth | Better Auth\n---|---\n`NEXTAUTH_SECRET` | `BETTER_AUTH_SECRET`\n`NEXTAUTH_URL` | `BETTER_AUTH_URL`\n`GITHUB_ID` | `GITHUB_CLIENT_ID`\n`GITHUB_SECRET` | `GITHUB_CLIENT_SECRET`\n`GOOGLE_ID` | `GOOGLE_CLIENT_ID`\n`GOOGLE_SECRET` | `GOOGLE_CLIENT_SECRET`\n\nOne 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.\n\n##  Step 6: The API route\n\nNextAuth mounts its handler at `[...nextauth]`. Better Auth uses a catch-all `[...all]` route wired to its handler:\n\n\n\n    // before — app/api/auth/[...nextauth]/route.ts\n    import NextAuth from \"next-auth\";\n    import { authOptions } from \"@/lib/auth\";\n    const handler = NextAuth(authOptions);\n    export { handler as GET, handler as POST };\n\n    // after — app/api/auth/[...all]/route.ts\n    import { auth } from \"@/lib/auth\";\n    import { toNextJsHandler } from \"better-auth/next-js\";\n    export const { GET, POST } = toNextJsHandler(auth);\n\n\nThis is a file move plus a rewrite, not an in-place edit — the directory name itself changes from `[...nextauth]` to `[...all]`.\n\n##  Step 7: Middleware → cookie check + server `requireSession`\n\nNextAuth's `withAuth` middleware has no direct equivalent in Better Auth, and that's deliberate. The recommended pattern is two layers:\n\n  1. A lightweight **cookie check in middleware/proxy** for cheap edge-level redirects.\n  2. An **authoritative server-side`requireSession()`** in the protected route or layout, which actually validates the session.\n\n\n\n\n    // before — middleware.ts\n    export { default } from \"next-auth/middleware\";\n\n    // after — a cookie presence check at the edge, plus:\n    const session = await requireSession(); // authoritative, server-side\n\n\nThe 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.\n\n##  The config, the schema, the data — what you still do by hand\n\nEverything 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.\n\n**The auth config itself.** Your `authOptions` / `NextAuth({})` body doesn't translate one-to-one. You port it deliberately:\n\n  * `providers: [...]` → `socialProviders: { ... }`\n  * your adapter → the matching Better Auth adapter (e.g. `drizzleAdapter`)\n  * `callbacks: { ... }` → `databaseHooks` or plugin options\n  * email/password, verification, and reset are explicit features you enable, not implicit defaults\n\n\n\n\n    // the shape you're porting *to* (illustrative)\n    export const auth = betterAuth({\n      database: drizzleAdapter(db, { /* ... */ }),\n      emailAndPassword: { enabled: true },\n      socialProviders: {\n        github: {\n          clientId: process.env.GITHUB_CLIENT_ID!,\n          clientSecret: process.env.GITHUB_CLIENT_SECRET!,\n        },\n      },\n    });\n\n\n**The database schema.** NextAuth's tables are not Better Auth's tables. You generate the Better Auth schema and migrate:\n\n\n\n    pnpm auth:generate   # produce the Better Auth schema\n    pnpm db:migrate      # apply it\n\n\n**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.\n\n##  A sane order to do this in\n\n  1. `git commit` or `stash` first — you want a clean diff to review.\n  2. Rewrite the call sites (imports, `getServerSession`, `useSession`, sign-in references).\n  3. Port the config, generate the schema, run the migration.\n  4. Fix the sign-in/sign-out **arguments** by hand.\n  5. Move the API route to `[...all]`.\n  6. Rebuild middleware as cookie-check + `requireSession`.\n  7. Rename `.env` entries.\n  8. Verify: run your typecheck, then `pnpm auth:generate && pnpm db:migrate`, then `pnpm dev` and click through the actual flows.\n\n\n\nOne 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.\n\n##  Disclosure: I build a starter that automates the mechanical part\n\nI 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.\n\nThose 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:\n\n\n\n    # preview every change — writes nothing\n    node ./migrate/cli.mjs --transform nextauth --dry ./src\n    # apply it (rewrites files in place)\n    node ./migrate/cli.mjs --transform nextauth ./src\n\n\nIt's idempotent (running it twice is safe), but commit or stash first regardless.\n\nWhat 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.\n\nWhat it explicitly does **not** do — and leaves marked so you can find the remainder with one grep — is the genuinely manual work:\n\n\n\n    grep -rn \"TODO(ship-kit)\" .   # the manual checklist the codemod left you\n\n\n…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.\n\nFor 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.\n\nIf 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.\n\n_Disclosure: I'm the author of Ship Kit, a commercial product mentioned above, and I earn revenue from its sales._",
  "title": "Migrating from NextAuth to Better Auth in Next.js (and What the Boring Parts Actually Are)"
}