DEV Community

Cover image for Moving off Clerk to Better Auth: what's mechanical and what's a rebuild
Ship Kit
Ship Kit

Posted on Edited on Originally published at betterauth.app

Moving off Clerk to Better Auth: what's mechanical and what's a rebuild

I've done a fair number of NextAuth to Better Auth moves, and they're mostly mechanical: same general shape, swap the library, fix a few call sites. Clerk is a different animal, and you should know that going in.

NextAuth is a library. Clerk is a hosted product: pre-built React components plus a hosted user store that lives on Clerk's servers. When you move off Clerk you're not swapping one auth library for another — you're replacing UI you never wrote, re-implementing session logic Clerk did for you, and migrating user records out of someone else's database. The import rewrites are find-and-replace. The rest is a rebuild. This post walks the whole thing so you can scope it honestly before you start.

Coming from NextAuth instead? See the companion guide: Migrating from NextAuth to Better Auth.

The two halves of a Clerk migration

Split the work into two buckets:

  1. Mechanical — imports and session calls. A codemod can handle the textual part and flag the spots where the return shapes don't line up.
  2. Rebuild — UI components, middleware, the auth config, and the user-data export. None of this is find-and-replace, because Clerk did it for you and Better Auth expects you to own it.

Let's do the mechanical half first, because it's the part that looks like a library swap.

Session calls: same intent, different shape

Clerk gives you auth() and currentUser() on the server, and useAuth() / useUser() on the client. Better Auth has one server call and one client hook. A codemod can rewrite the calls, but the return shapes are different, and that's the part you have to fix by hand.

Here's a typical server component before:

import { auth, currentUser } from "@clerk/nextjs/server";

export default async function Page() {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");
  const user = await currentUser();
  return <div>Hello {user.firstName}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The shape you're moving to is different. Better Auth's getSession returns { user, session } or null — there is no userId at the top level. A naive rewrite of the destructure would compile and be subtly wrong, so the safe approach is to leave a TODO at every rewritten call rather than guess a "plausible" shape:

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export default async function Page() {
  // TODO(better-auth): Clerk auth() returned { userId }; getSession returns
  // { user, session } | null. Destructure from session.user, not userId.
  const { userId } = await auth.api.getSession({ headers: await headers() });
  if (!userId) redirect("/sign-in");
  const user = await currentUser(); // TODO(better-auth): fold into the session above
  return <div>Hello {user.firstName}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The point of leaving the broken destructure next to a TODO is that it fails loudly instead of compiling into something quietly wrong. The corrected version a human writes:

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

export default async function Page() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect("/sign-in");
  return <div>Hello {session.user.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

One call now covers both auth() and currentUser() — the user object is already on the session, so the separate currentUser() round-trip goes away.

The client side is the same idea. Before:

"use client";
import { useUser, useAuth } from "@clerk/nextjs";

export function Profile() {
  const { isSignedIn } = useAuth();
  const { user } = useUser();
  if (!isSignedIn) return null;
  return <span>{user.fullName}</span>;
}
Enter fullscreen mode Exit fullscreen mode

After the call rewrite, with the shape fix still owed to you:

"use client";
import { authClient } from "@/lib/auth-client";

export function Profile() {
  // TODO(better-auth): useSession() returns { data, isPending, error }.
  // data is { user, session } | null. Replace isSignedIn / user accordingly.
  const { isSignedIn } = authClient.useSession();
  const { user } = authClient.useSession();
  return null;
}
Enter fullscreen mode Exit fullscreen mode

authClient.useSession() returns { data, isPending, error }, and data is your { user, session } or null. The human-fixed version:

"use client";
import { authClient } from "@/lib/auth-client";

export function Profile() {
  const { data, isPending } = authClient.useSession();
  if (isPending || !data) return null;
  return <span>{data.user.name}</span>;
}
Enter fullscreen mode Exit fullscreen mode

So that's the mechanical half: drop the @clerk/* imports, point them at @/lib/auth, next/headers, and @/lib/auth-client, and rewrite the call sites. A codemod can do the textual part, but it can't make the shapes match — that's on you, which is why every rewritten call is worth marking with a TODO before you move on.

The rebuild half: UI components

This is where Clerk-being-a-product hurts. Clerk ships React components that render entire flows. Better Auth ships none of that — it's headless. So every Clerk component is a TODO, not a rewrite. A codemod can remove the component imports and note what it pulled, but it cannot generate UI for you.

What you have to replace by hand:

  • <ClerkProvider> — delete it. Better Auth has no client-side provider to wrap your app in; the session hook works without one.
  • <SignedIn> / <SignedOut> — these gated children on auth state. Replace with an actual session check: read the session (server) or useSession() (client) and branch.
  • <UserButton> — the avatar dropdown with sign-out. You build this: an account menu and a sign-out button calling authClient.signOut().
  • <SignIn> / <SignUp> — the hosted forms. These become real routes and real components — an (auth) route group plus form components under src/components/auth/.
  • <Protect> / <RedirectToSignIn> — replace with your own session-gating and redirects.

There's no shortcut here. If you were leaning on Clerk's components, budget real time to build sign-in, sign-up, and the account menu as first-class UI. A starter that already ships these (more on that at the end) saves you most of this, but the work itself is genuine UI work either way.

Middleware

Clerk's clerkMiddleware() (formerly authMiddleware()) ran auth on the edge for you. This is a rewrite, not a rename — there's no clean textual mapping. You replace it with an edge check plus an authoritative server check.

On Next.js 16 the file is src/proxy.ts; on Next.js 15 and below it's middleware.ts. The pattern is a cheap cookie check at the edge, then a real requireSession call in the server code that actually loads protected data. The edge check keeps unauthenticated traffic out early; the server check is the one that's authoritative. Don't rely on the edge check alone for anything that matters — it only inspects the cookie, it doesn't validate the session against the database.

The auth config and the API route

Two more things a codemod can't write for you, because there's nothing in your Clerk code to translate from:

  • betterAuth({}) — the server config: your database adapter, which providers you enable, session options. This didn't exist under Clerk (Clerk was the config, hosted), so it's net-new.
  • The [...all] API route — Better Auth handles its own endpoints through a catch-all route handler. Clerk routed to its own servers, so again, net-new.

Environment variables

Don't reuse anything. Specifically:

  • Generate a fresh BETTER_AUTH_SECRET. Do not reuse CLERK_SECRET_KEY — it's a different key for a different system. Generate a new secret.
  • Delete the NEXT_PUBLIC_CLERK_* routing vars — NEXT_PUBLIC_CLERK_SIGN_IN_URL and friends. Those pointed at Clerk's hosted flows; your routes live in your app now, so they're dead config.

Cleaning up .env is on you regardless of tooling.

The part nobody warns you about: user data

Your users live in Clerk's database, not yours. Moving off Clerk means a real data migration, and this is usually the line item people forget to scope.

The shape of it:

  1. Export users from Clerk (their API / dashboard export).
  2. Import them into your user table so Better Auth can read them.
  3. Password hashes carry over. The Clerk Dashboard export is a CSV that includes hashed passwords: password_digest is the hash, password_hasher is the algorithm. Clerk hashes with bcrypt and Better Auth defaults to scrypt, so you set Better Auth's password hash/verify to bcrypt and write each password_digest into the password field of that user's credential account. Better Auth's Clerk migration guide covers both, script included. Password users then sign in with the password they already had, no reset.
  4. Check password_hasher on every row. The guide's script assumes every row is bcrypt and never checks that column. A row with any other hasher won't verify against a bcrypt config, so that user needs a password reset. Find those rows before cutover, not after, and treat their reset as its own release gate (a good point from the comments): tell them it's coming, and don't call the cutover done until a test reset email lands and the new password works.
  5. OAuth users come back through the social flow. For GitHub and Google users the provider is the identity, so they just sign in again. That's also why a broken password import hides in testing: you log in with Google, it works, you ship. Before cutover, create a password test user in Clerk, push it through the export and import, and sign in with it.

None of this is codemod work. The codemods transform code; this is data. And once the hashes come across, passwords stop being the scary part of the move. What still makes a Clerk migration more than a weekend job is the rest of this post: rebuilding the UI that Clerk's components were rendering for you, and moving the user data across without losing anyone.

Correction (Oct 18): an earlier version of this section said Clerk password hashes can't be carried over, so every password user would need a reset. That was wrong: Clerk's dashboard export includes them.

Scoping it honestly

Mechanical: imports and session calls (with the shape fixes you do by hand). Rebuild: every Clerk component, middleware, the betterAuth({}) config, the [...all] route, the .env cleanup, and the user-data export. Expect more manual work than a NextAuth migration — NextAuth is a library-to-library move; Clerk is a product-to-library move, and the product included a lot you now own.


I build Ship Kit (betterauth.app), a production-ready Better Auth starter for Next.js 16 + TypeScript. Its migration codemods are free and open source — authlayerdev/ship-kit-migrate (MIT), deterministic and non-AI (Clerk and NextAuth). Clone the repo and run the Clerk transform against your project, dry-run first:

# preview every change — writes nothing
node ./migrate/cli.mjs --transform clerk --dry ./src
# apply it
node ./migrate/cli.mjs --transform clerk ./src
# then find the manual remainder it marked for you:
grep -rn "TODO(ship-kit)" .
Enter fullscreen mode Exit fullscreen mode

Being straight about what they do: they're an assist, not a one-click migrator. They delete your Clerk wiring and mark the spots that need a human — the import and session-call rewrites, plus // TODO(ship-kit): markers on the shape mismatches and on removed component imports (and they intentionally leave a wrong destructure like const { userId } = … in place so the TODO forces you to fix the session read, never a silently-compiling-but-wrong one). They do not recreate Clerk's components, middleware, or config for you, and a Clerk migration is more manual than the NextAuth one for exactly the product-vs-library reason above. The shape fixes are still yours to make.

What the kit gives you on the other side is the UI and wiring you'd otherwise hand-build: (auth) routes and components, the account menu and sign-out, two-layer route protection (edge cookie check + server requireSession), organizations/teams, and org-level Stripe billing. It's pinned to better-auth 1.6.18 with a nightly canary CI job that bumps Better Auth to latest and re-runs the test suite — that's a CI signal, not a promise that upgrades are painless. It's brand new, so there are no buyer testimonials yet; honest competitors like Makerkit (from $349) and supastarter (from EUR349) are more mature with more templates, and NEXTY (~$188) is cheaper. Ship Kit's distinct edges are the in-product migration codemods (rivals are docs-only) and crypto checkout. Solo is $179, Agency $499 — one-time (not a subscription), lifetime + free updates, crypto -5%, 14-day refund.

Disclosure: Ship Kit is my commercial product and I earn money when you buy it. It is independent and unofficial — not affiliated with or endorsed by Better Auth.

Top comments (9)

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The decision to leave a deliberately broken destructure next to a TODO instead of guessing a plausible shape is the most honest migration advice here. Most codemods optimize for a green build, which is exactly how silent session-shape bugs ship. On the rebuild side, the middleware rewrite from clerkMiddleware to the edge-cookie-check plus requireSession pattern feels like the part teams underestimate the most.

Collapse
 
authlayerdev profile image
Ship Kit •

agreed, and I think it's underestimated because clerkMiddleware ran auth on the edge for you, so after the move the instinct is to port that file 1:1 and call it done. but the edge check only looks at the cookie. it never asks the database whether the session is real. the authoritative check has to live next to the data: requireSession in the server component or route handler that actually reads it. quick test: if deleting your proxy.ts would leak data, the gate is in the wrong place.

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

That matches the middleware section well. The edge cookie check plus a real requireSession call looks simple on paper, but I wonder how many teams discover the gap only after deleting clerkMiddleware and watching protected routes lose auth entirely. Does the Ship Kit codemod flag middleware files for manual review, or is that the part people are expected to catch themselves?

Thread Thread
 
authlayerdev profile image
Ship Kit •

it does, by making the file fail typecheck: the transform removes the @clerk/nextjs/server import but leaves the clerkMiddleware() call in place, so it's an undefined reference until a human replaces it.

that's on purpose, because the failure you're describing (middleware gone, protected routes wide open) is the worst thing a migration tool can do silently. there's a // TODO(ship-kit): above the call pointing at the replacement, an edge cookie check in proxy.ts plus a real requireSession() in protected routes and layouts.

what it won't do is write that replacement, and it doesn't touch your matcher config either, so both are still on you. I'd rather hand you a red typecheck than a green deploy with no auth.

Thread Thread
 
sgaggjhkjh profile image
Arjun Sharma •

That makes the design choice much clearer. Leaving the matcher config untouched is the right call: a wrong automated guess there could silently expose a protected route, so failing loudly beats failing open. Do you recommend any particular way to audit the matcher entries after the migration, or is it a manual route-by-route review?

Collapse
 
vladzoff profile image
Vlad Zoff •

I'd separate API replacement from behavior migration. Inventory sessions, callbacks, password resets, account linking, MFA, and webhook assumptions first. These mismatches tend to appear after the happy-path login works, not during the initial migration.

Collapse
 
authlayerdev profile image
Ship Kit • • Edited

edit: the hash part of this is wrong. Clerk's dashboard export does include them, correction below.

behavior migration is a better name for it than the one I used. the clearest example from this one: password users can't come across with their hashes, so the first time they show up after cutover they hit a reset. the code for that is small. the support load and the email you send beforehand are not. OAuth users just sign in again through the social flow, which is exactly why the password cohort is easy to miss in testing: you log in with Google, it works, you ship.

Collapse
 
vladzoff profile image
Vlad Zoff •

That's a good reason to treat the password cohort as a separate migration path, not just another row in the auth test matrix. I'd make the reset flow an explicit release gate: verify the advance communication, reset email delivery, and successful recovery before considering the cutover complete. A working OAuth login only proves that one cohort survived the migration.

Thread Thread
 
authlayerdev profile image
Ship Kit •

correction, because I got this wrong above: Clerk's dashboard CSV export does include password hashes, password_digest plus the algorithm in password_hasher. Better Auth's Clerk migration guide has you set Better Auth's password hash/verify to bcrypt (its default is scrypt), so password users keep their existing passwords. the catch: the guide's script assumes every row is bcrypt and never checks password_hasher, so check it per row and send anything else through a reset. your release gate still holds for that path (non-bcrypt rows, or anyone who skips the import), it's just no longer the whole password cohort. correcting the article now.