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:
- Mechanical — imports and session calls. A codemod can handle the textual part and flag the spots where the return shapes don't line up.
- 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>;
}
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>;
}
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>;
}
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>;
}
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;
}
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>;
}
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) oruseSession()(client) and branch. -
<UserButton>— the avatar dropdown with sign-out. You build this: an account menu and a sign-out button callingauthClient.signOut(). -
<SignIn>/<SignUp>— the hosted forms. These become real routes and real components — an(auth)route group plus form components undersrc/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 reuseCLERK_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_URLand 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:
- Export users from Clerk (their API / dashboard export).
-
Import them into your
usertable so Better Auth can read them. -
Password hashes carry over. The Clerk Dashboard export is a CSV that includes hashed passwords:
password_digestis the hash,password_hasheris the algorithm. Clerk hashes with bcrypt and Better Auth defaults to scrypt, so you set Better Auth's passwordhash/verifyto bcrypt and write eachpassword_digestinto thepasswordfield 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. -
Check
password_hasheron 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. - 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)" .
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)
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.
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.
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?
it does, by making the file fail typecheck: the transform removes the
@clerk/nextjs/serverimport but leaves theclerkMiddleware()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 inproxy.tsplus a realrequireSession()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.
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?
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.
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.
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.
correction, because I got this wrong above: Clerk's dashboard CSV export does include password hashes,
password_digestplus the algorithm inpassword_hasher. Better Auth's Clerk migration guide has you set Better Auth's passwordhash/verifyto 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 checkspassword_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.