Migrating from Auth.js to Better Auth: A Step-by-Step Guide

npmixnpmix
176
Authjs and Nextjs

Thinking about switching authentication providers in your Next.js app? Better Auth offers more flexibility, cleaner APIs, and faster performance. Here's a complete step-by-step guide to migrating from Auth.js to Better Auth—without losing data or breaking your app.

🔄 TL;DR

  • ✅ Fully migrate from Auth.js to Better Auth without database schema changes
  • 🔄 Map user, session, and account schemas with field mapping (no need to rename columns)
  • 🔐 Update route handlers and session logic for Next.js App Router
  • 📦 Replace useSession and signIn with Better Auth's type-safe alternatives
  • 💡 Keep all existing user sessions active during and after migration

🧭 Why Migrate to Better Auth?

Before diving into the migration steps, here's why many teams are making the switch:

  • Type-safe APIs with full TypeScript support and better developer experience
  • Granular control over authentication flow, session handling, and database schema
  • Significantly improved performance in large-scale applications
  • Built-in support for modern auth flows including passwordless, multi-provider login, and MFA
  • Simplified OAuth integration with major providers like Google, GitHub, and Discord

📁 Step 1: Install Better Auth

If you haven't already, install Better Auth in your project:

bash
npm install better-auth
# or
yarn add better-auth
# or
pnpm add better-auth

Then set up the core configuration in your auth.ts file:

typescript
// server/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "@prisma/client";
import { nextCookies } from "better-auth/nextjs";

const prisma = new PrismaClient();

export const auth = betterAuth({
  secret: process.env.BETTER_AUTH_SECRET,
  emailAndPassword: {
    enabled: true,
  },
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  plugins: [nextCookies()],
});

Make sure to set the BETTER_AUTH_SECRET environment variable in your .env file. This should be a strong, random string (at least 32 characters).

🧱 Step 2: Map Your Database Schema

One of the biggest advantages of Better Auth is that you don't need to rename your database fields. Instead, you can map your existing Auth.js schema to Better Auth's expected structure.

👤 User Schema Mapping

Auth.js FieldBetter Auth FieldNotes
emailVerified (v4)emailVerifiedchange datetime → boolean

📑 Session Schema Mapping

Auth.js FieldBetter Auth FieldNotes
expiresexpiresAt
sessionTokentoken
(Add)createdAt, updatedAtfields (datetime)

In your auth.ts configuration:

typescript
export const auth = betterAuth({
  // Other configs
  session: {
    fields: {
      expiresAt: "expires",
      token: "sessionToken",
    },
  },
});

🧾 Account Schema Mapping

Auth.js FieldBetter Auth FieldNotes
provider (v4)providerId
providerAccountIdaccountId
refresh_tokenrefreshToken
access_tokenaccessToken
access_token_expiresaccessTokenExpiresAt
expires_at (v4)accessTokenExpiresAt
id_tokenidToken
(Remove)session_state, type, token_typeNot required by Better Auth
(Add)createdAt, updatedAtfields (datetime)

In your auth.ts configuration:

typescript
export const auth = betterAuth({
  // Other configs
  account: {
    fields: {
      providerId: "provider", // For Auth.js v4
      accountId: "providerAccountId",
      refreshToken: "refresh_token",
      accessToken: "access_token",
      accessTokenExpiresAt: "expires_at", // For Auth.js v4
      idToken: "id_token",
    },
  },
});

📐 Step 3: Update Your Prisma Schema

If you're using Prisma, you can keep your existing schema and use the @map() directive to map the fields:

prisma
model Session {
  id        String   @id @default(cuid())
  expiresAt DateTime @map("expires")
  token     String   @unique @map("sessionToken")
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@index([userId])
}

model Account {
  id                String   @id @default(cuid())
  userId            String
  providerId        String   @map("provider")
  accountId         String   @map("providerAccountId")
  refreshToken      String?  @map("refresh_token")
  accessToken       String?  @map("access_token")
  accessTokenExpiresAt DateTime? @map("expires_at")
  idToken           String?  @map("id_token")
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt
  user              User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([providerId, accountId])
  @@index([userId])
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified Boolean?  @default(false)
  image         String?
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

After updating your schema, run a migration:

bash
npx prisma migrate dev --name better-auth-migration

🔁 Step 4: Update Your Route Handler

In your app/api/auth directory, rename the [...nextauth] folder to [...all] to avoid confusion. Then, update the route.ts file:

typescript
// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "~/server/auth";

export const { POST, GET } = toNextJsHandler(auth);

This handler will process all authentication-related requests, including sign-in, sign-out, and session management.

🧠 Step 5: Refactor the Client

Create a reusable auth client file to centralize your authentication hooks and functions:

typescript
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_BASE_URL, // Optional if your API is on the same domain
});

Example: Social Login with Discord

Update your social login functions to use Better Auth's type-safe API:

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

export const signInGoogle = async () => {
  const { data, error } = authClient.signIn.social({
    provider: "google",
    callbackUrl: "/dashboard", // Optional redirect after successful login
  });

  if (error) {
    console.error("Failed to sign in:", error.message);
    return null;
  }

  // Success! User is now logged in
  return data;
};

Example: Email and Password Login for server component

To enable email and password authentication, you need to set the emailAndPassword.enabled option to true in the auth configuration.

typescript
// @/lib/auth.ts
import { betterAuth } from "better-auth";
 
export const auth = betterAuth({
  emailAndPassword: { 
    enabled: true, 
  }, 
});
typescript
"use server";

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

export const signInWithEmail = async (email: string, password: string) => {
  try {
    await auth.api.signInEmail({
      body: {
        email,
        password,
      },
    });

    return {
      success: true,
      message: "Signed in successfully.",
    };
  } catch (error) {
    const e = error as Error;

    return {
      success: false,
      message: e.message || "An unknown error occurred.",
    };
  }
};

Example: Email and Password Login for client component

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

export const signInWithEmail = async (email: string, password: string) => {
  const { data, error } = authClient.signIn.email({
    email,
    password,
    callbackUrl: "/dashboard", // Optional redirect after successful login
  });

  if (error) {
    return { success: false, message: error.message };
  }

  return { success: true, data };
};

Example: Replace useSession

Replace Auth.js's useSession hook with Better Auth's version:

typescript
"use client"

import { createAuthClient } from "better-auth/react"
const { useSession } = createAuthClient() 

export const Profile = () => {
  const { data: session, isPending, error } = useSession();

  if (isPending) return <div>Loading user information...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!session) return <div>Please sign in to view your profile</div>;

  return (
    <div>
      <h1>Welcome, {session.user.name || 'User'}</h1>
      <img src={session.user.image || '/default-avatar.png'} alt="Profile" />
      <pre>{JSON.stringify(session, null, 2)}</pre>
    </div>
  );
};

🧾 Step 6: Server Actions and Middleware

Server-Side Session Access

For server components or server actions, use the auth instance to get session data:

typescript
// Server Component
// app/dashboard/page.tsx

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

export default async function Dashboard() {
  const session = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    redirect("/login");
  }

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      {/* Dashboard content */}
    </div>
  );
}
typescript
// Server Action
"use server";

import { auth } from "~/server/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";

export const updateUserProfile = async (formData: FormData) => {
  const session = await auth.api.getSession({
    headers: headers(),
  });

  if (!session) {
    throw new Error("Unauthorized");
  }

  const updateProfile = await prisma.user.update({
    where: {
      id: session.user.id,
    },
    data: {
      name: formData.get("name") as string,
      email: formData.get("email") as string,
    },
  });

  revalidatePath("/profile");
  return { success: true };
};
typescript
// app/profile/page.tsx

import {updateUserProfile} from "@/server/actions";

export default function Profile() {
  return (
    <div>
      <h1>Profile</h1>
      <form action={updateUserProfile}>
        <input type="text" name="name" />
        <input type="email" name="email" />
        <button type="submit">Update Profile</button>
      </form>
    </div>
  );
}

Middleware (Optional)

To protect routes with middleware, create a middleware.ts file in your project root:

typescript
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function middleware(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);

  // Redirect unauthenticated users to login page
  if (!sessionCookie && !request.nextUrl.pathname.startsWith("/login")) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  // Optional: Role-based access control
  if (
    sessionCookie &&
    request.nextUrl.pathname.startsWith("/admin") &&
    sessionCookie.user.role !== "admin"
  ) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/profile/:path*", "/admin/:path*"],
};

✅ Final Checklist

TaskStatusNotes
Install Better Authnpm install better-auth
Update auth configurationMap fields to preserve existing data
Update Prisma schemaUse @map() directives
Update route handlerRename [...nextauth] to [...all]
Create auth clientExport hooks and functions
Update client componentsReplace useSession and signIn calls
Update server componentsUse auth.api.getSession
Configure middlewareProtect routes as needed

🎉 Wrapping Up

You've successfully migrated from Auth.js to Better Auth! Your application now benefits from:

  • Type-safe authentication APIs
  • Improved performance and security
  • More flexible session management
  • Better developer experience

All of this without losing any user data or breaking existing sessions.

Want to explore more? Check out the Better Auth docs or clone the demo repository for complete examples.

🙋‍♂️ FAQ

Q: Does this migration break existing user sessions?
A: No—if you map your fields properly, existing users will remain logged in throughout and after the migration.

Q: Can I use this with server actions and server components?
A: Yes. Better Auth works seamlessly with Next.js 14/15 features including server components, server actions, and middleware.

Q: What about OAuth providers like Google/GitHub/Discord?
A: They're fully supported via the .social() method. Better Auth simplifies OAuth integration with improved error handling.

Q: Do I need to change my database schema?
A: No. You can map existing fields without renaming them, allowing for a smooth migration without data loss.

Q: How do I handle custom fields in my user model?
A: Better Auth supports custom fields through the user.fields configuration option, giving you full control over your data model.

Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.