In this article
July 7, 2026
July 7, 2026

Migrating from Better Auth to WorkOS AuthKit: A Next.js guide

The code side of the move: routes, session management, reading the user, and step up auth in a Next.js App Router app.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

If you have decided to move off Better Auth, the data half of the migration is covered separately in what the Vercel acquisition changes and how to migrate to WorkOS, which walks through exporting users, passwords, and organizations and handling the cutover.

This guide is the other half: the code. If you are on Better Auth with a Next.js App Router app, here is how to wire in AuthKit.

Before you start

Two prerequisites that are easy to miss:

  • Next.js App Router. The library is built for it and is not intended for the Pages Router.
  • Node 22.11.0 or later. The package declares it in engines, so an older runtime fails at install rather than at runtime.

Step 1: Install AuthKit

AuthKit ships a first-party Next.js library. Install it alongside the WorkOS Node SDK, which is a peer dependency:

  
pnpm i @workos-inc/authkit-nextjs @workos-inc/node
  

Stay on the 4.x line. Check npm for the current release rather than pinning to a version you read in a blog post.

Step 2: Configure environment variables

Grab your client ID and API key from the WorkOS dashboard and add four values to .env.local:

  
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_COOKIE_PASSWORD="" # at least 32 characters
NEXT_PUBLIC_WORKOS_REDIRECT_URI="http://localhost:3000/callback"
  

WORKOS_COOKIE_PASSWORD is the private key that encrypts the session cookie. It has to be at least 32 characters. Generate one with openssl:

  
openssl rand -base64 24
  

If you are coming from Better Auth, this is the mental shift. Instead of configuring a database adapter and running auth tables yourself, you point at a hosted service and let it hold the session.

Step 3: Add the callback and sign-in routes

WorkOS redirects users back to your app after they authenticate. Create a callback route handler that matches your NEXT_PUBLIC_WORKOS_REDIRECT_URI. For the value above, that is app/callback/route.ts:

  
import { handleAuth } from '@workos-inc/authkit-nextjs';

export const GET = handleAuth();
  

You can send users to a specific page after sign-in with returnPathname:

  
export const GET = handleAuth({ returnPathname: '/dashboard' });
  

Then add a route that starts the sign-in flow, and set it as the sign-in URL in the dashboard under Redirects:

  
// app/sign-in/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';

export const GET = async () => {
  const signInUrl = await getSignInUrl();
  return redirect(signInUrl);
};
  

Do not skip the dashboard step. The sign-in URL is what lets WorkOS-initiated flows, impersonation among them, route through your app first so the PKCE and CSRF checks can be set up. Without it those flows fail with a missing auth parameter error.

Step 4: Wire up session management

AuthKit uses Next.js proxy, which was called middleware in Next.js 15 and earlier. For Next.js 16 and later, create proxy.ts in the project root. For Next.js 15 and earlier, create middleware.ts:

  
// proxy.ts (Next.js 16+)
import { authkitProxy } from '@workos-inc/authkit-nextjs';

export default authkitProxy();

export const config = { matcher: ['/', '/admin'] };
  
  
// middleware.ts (Next.js 15 and earlier)
import { authkitMiddleware } from '@workos-inc/authkit-nextjs';

export default authkitMiddleware();

export const config = { matcher: ['/', '/admin'] };
  

The matcher array is where you declare which routes require auth, the direct analog to the route protection you were doing by hand.

One trap worth knowing before you reach for a catch-all pattern: a broad matcher intercepts static assets, which breaks styles, and Tailwind v4 is particularly prone to it. If you need broad coverage, exclude the Next.js static paths:

  
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
  

Step 5: Read the user

Wrap your root layout in AuthKitProvider so client components can reach auth state:

  
import { AuthKitProvider } from '@workos-inc/authkit-nextjs/components';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AuthKitProvider>{children}</AuthKitProvider>
      </body>
    </html>
  );
}
  

In a server component, withAuth() returns the current user or null:

  
import { withAuth } from '@workos-inc/authkit-nextjs';

export default async function DashboardPage() {
  const { user } = await withAuth();
  // render for user, or send them to sign in
}
  

In a client component, use useAuth(). Note the import path, because this one catches people out:

  
'use client';
import { useAuth } from '@workos-inc/authkit-nextjs/components';

export default function Greeting() {
  const { user, loading } = useAuth();

  if (loading) return <div>Loading...</div>;
  return <div>{user?.firstName}</div>;
}
  

Client-side helpers come from @workos-inc/authkit-nextjs/components, not the package root. Importing withAuth into a client component instead of using useAuth produces an UnhandledSchemeError on node:crypto, which is a confusing error for a simple mistake.

For pages where a signed-in user is mandatory, pass ensureSignedIn and AuthKit redirects unauthenticated visitors for you:

  
const { user } = await withAuth({ ensureSignedIn: true });
  

That replaces the getSession checks and manual redirects you wrote against Better Auth. One caveat: do not wrap it in a try/catch, because withAuth redirects, and Next.js requires redirects to be called outside try/catch.

Step 6: Move your users over

The routing swap is the easy part. Moving live users without forcing a mass password reset is the part that needs a plan, and it is covered in full in the Better Auth migration guide and the companion post: exporting from your Better Auth tables, importing users and scrypt password hashes, moving organizations and memberships, and choosing between pausing signups or dual-writing during the cutover.

What belongs here is the hook that makes reconciliation possible. handleAuth accepts an onSuccess function that receives the authenticated user along with oauthTokens, authenticationMethod, organizationId, and any state you passed through the flow, so you can match a WorkOS user against your existing records on first login:

  
export const GET = handleAuth({
  onSuccess: async ({ user, oauthTokens, authenticationMethod }) => {
    await linkExistingAccount(user);
  },
});
  

authenticationMethod is only present on the initial authentication callback, not on later requests or session refreshes, so do any recording of it here.

What you get past the swap

The point of moving is not a different sign-in button. It is that a category of work becomes configuration instead of a project. Enterprise SSO and directory sync become dashboard settings rather than sprints.

Step up auth is a good example of something you would otherwise build. checkRecentAuth reads the access token's auth_time claim and tells you whether the user authenticated recently. It returns data and never redirects, so it is safe to call as the enforcement step inside a server action, and it fails closed: a session with no usable auth_time is reported as stale.

  
'use server';

import { checkRecentAuth, getSignInUrl } from '@workos-inc/authkit-nextjs';
import { redirect } from 'next/navigation';

export async function deleteAccount() {
  const { isStale } = await checkRecentAuth({ maxAge: 300 });

  if (isStale) {
    redirect(await getSignInUrl({ maxAge: 300 }));
  }

  // perform the sensitive action
}
  

Passing maxAge to getSignInUrl forwards the OIDC max_age parameter, so AuthKit forces a fresh login rather than accepting the existing session. There is a useRecentAuth hook for reflecting staleness in the UI, but treat it as presentation only and always enforce on the server.

Wrapping up

Better Auth earned its reputation. It went from first release in September 2024 to 1.0 by that November, took over Auth.js along the way, and is heading somewhere interesting with Vercel. If owning your auth stack is a feature for your team, that is a reasonable place to be.

If your app is past the prototype stage and auth has become load-bearing for revenue, the six steps above are the whole code migration. Start with the WorkOS dashboard and the authkit-nextjs library, and use the migration guide for the data.