DEV Community

Ship Kit
Ship Kit

Posted on • Originally published at betterauth.app

Migrating from NextAuth to Better Auth in Next.js (and What the Boring Parts Actually Are)

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 sitesgetServerSession 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";
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. A lightweight cookie check in middleware/proxy for cheap edge-level redirects.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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: { ... }databaseHooks or 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!,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. git commit or stash first — you want a clean diff to review.
  2. Rewrite the call sites (imports, getServerSession, useSession, sign-in references).
  3. Port the config, generate the schema, run the migration.
  4. Fix the sign-in/sign-out arguments by hand.
  5. Move the API route to [...all].
  6. Rebuild middleware as cookie-check + requireSession.
  7. Rename .env entries.
  8. Verify: run your typecheck, then pnpm auth:generate && pnpm db:migrate, then pnpm dev and 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 sourceauthlayerdev/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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

…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)