Moving off Clerk to Better Auth: what's mechanical and what's a rebuild
June 22, 2026
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. - You cannot carry over password hashes. Clerk doesn't hand you usable password hashes, so password users have to go through a password reset on first login. OAuth users (GitHub, Google) re-onboard through the social flow — their identity is the provider, so they just sign in again.
Plan the comms for this. If you have password users, they'll all need a reset email or a "set a new password" prompt the first time they show up after the cutover. That's a product decision, not just a code one, and it's the single biggest reason a Clerk migration isn't a weekend job.
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.