July 13, 2026


References: Better Auth documentation ยท Prisma + Better Auth guide ยท jb.desishub.com โ Better Auth + Prisma guide
This guide assumes you already have Prisma 7 set up with the
@prisma/adapter-pgdriver adapter and a Postgres database (Neon or Prisma Postgres). If you haven't done that part yet, see Set Up Prisma 7 in Next.js 15+ with Neon DB or Prisma Postgres first โ this guide picks up right where that one ends.
Better Auth is a TypeScript-first authentication library that doesn't lock you into its own database tables โ it plugs directly into your existing Prisma schema through an adapter. That means you get full control over your User model: adding fields like firstName, lastName, or phone is just a schema change plus a small config block, not a fight against a black-box auth provider.
The important idea is this:
Better Auth needs three things working together: a
schema.prismafile with the four required auth models (User,Session,Account,Verification), anauth.tsconfig file that wires Better Auth to your Prisma client through the Prisma adapter, and an API route that exposes Better Auth's endpoints to your Next.js app.
Once those three pieces agree with each other, everything else โ sign up, sign in, email verification, password reset, custom fields โ works the same regardless of which Postgres provider you're using.
By the end, you will have this:
app/
api/
auth/
[...all]/
route.ts # exposes Better Auth's endpoints
lib/
auth/
auth.ts # Better Auth server config
auth-client.ts # Better Auth client hooks
prisma/
schema.prisma # User, Session, Account, Verification models
prisma-instance.ts # Prisma client singleton (driver adapter)
.envA working authentication system with:
Email + password sign up and sign in
Custom user fields (firstName, lastName, phone)
Email OTP verification on sign up
Password reset via emailBefore installing Better Auth, make sure you already have:
@prisma/adapter-pg driver adapter โ see the Prisma 7 setup guide if you haven't done this yetprisma/schema.prisma file that at least has a generator client block with a custom output path (Better Auth's Prisma adapter requires this in Prisma 7+)Check which package manager you're using before continuing โ every command below is shown for both npm and pnpm.
pnpm add better-authThat's the only package you need for Better Auth itself โ the Prisma adapter ships inside the better-auth package, so there's no separate adapter package to install.
Better Auth uses a secret key to sign session tokens and cookies. Generate one and add it to your .env:
pnpm dlx @better-auth/cli secretCopy the generated value into .env, along with the base URL of your app:
BETTER_AUTH_SECRET="paste-the-generated-secret-here"
BETTER_AUTH_URL="http://localhost:3000"BETTER_AUTH_URL should point to your production URL once deployed โ set it as an environment variable on your host at that time.
Better Auth needs four models in your database: User, Session, Account, and Verification. You can add your own extra fields to User (like firstName, lastName, phone), but don't rename these four models or their required fields โ Better Auth's Prisma adapter reads them by exact name.
generator client {
provider = "prisma-client"
output = "../lib/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum Role {
USER
ADMIN
}
model User {
id String @id @default(cuid())
firstName String
lastName String
name String
email String @unique
emailVerified Boolean @default(false)
image String?
phone String? @unique
role Role @default(USER)
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
@@map("user")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("session")
}
model Account {
id String @id @default(cuid())
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("account")
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("verification")
}A few things worth noting here:
firstName, lastName, phone, role, banned, banReason, and banExpires are not part of Better Auth's core schema โ they're custom fields. Adding them to the model isn't enough on its own; Step 5 shows how to tell Better Auth about them so it actually populates them on sign up.@@map("user") (lowercase, matching your Postgres table naming convention) is optional โ Better Auth doesn't care what the underlying table is called, only that the Prisma model exposes the right fields.@prisma/adapter-pg), your generator client block needs a custom output path, as shown above โ Better Auth's Prisma adapter requires this starting from Prisma 7.Push or migrate your schema:
pnpm prisma migrate dev --name add_better_auth_models
pnpm prisma generateIf you get a "drift detected" error at this point, it usually means the database is out of sync with your migration history โ see the troubleshooting note at the bottom of this guide.
If you haven't already got one from your Prisma setup, create prisma/prisma-instance.ts:
import { PrismaClient } from "@/lib/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = global as unknown as {
prisma: PrismaClient;
};
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL,
});
const db =
globalForPrisma.prisma ||
new PrismaClient({
adapter,
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
export default db;This is the same singleton pattern used across the rest of your app โ one shared Prisma client that survives Next.js's hot reload in development instead of opening a new database connection on every file save.
lib/auth/auth.ts)This is the central file. It wires Better Auth to Prisma, declares your custom user fields, and turns on the auth methods you want.
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { emailOTP } from "better-auth/plugins";
import db from "@/prisma/prisma-instance";
import { sendVerificationEmail as sendOtpEmail } from "@/lib/email/send-verification-email";
import { sendPasswordResetEmail } from "@/lib/email/send-password-reset-email";
export const auth = betterAuth({
database: prismaAdapter(db, {
provider: "postgresql",
}),
/* Declare custom fields on the User model here.
Without this block, Better Auth will silently drop
firstName / lastName / phone even if they exist in
your Prisma schema. */
user: {
additionalFields: {
firstName: {
type: "string",
required: true,
input: true,
},
lastName: {
type: "string",
required: true,
input: true,
},
phone: {
type: "string",
required: false,
input: true,
},
role: {
type: "string",
required: false,
input: false, // don't let users set their own role on sign up
defaultValue: "USER",
},
},
},
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
autoSignIn: false, // require email verification before session starts
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail({
to: user.email,
resetLink: url,
});
},
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
},
},
plugins: [
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
if (type === "email-verification") {
await sendOtpEmail({ to: email, code: otp });
}
},
}),
nextCookies(), // must be the last plugin in the array
],
});A few things to point out:
additionalFields is the piece most people miss. Without it, even though firstName/lastName/phone exist as real columns in Postgres, Better Auth's internal validation layer won't forward them from signUpEmail's body into the Prisma create() call โ you'll see errors like Unknown argument 'firstName' even though the column exists.emailOTP is the plugin responsible for the 6-digit verification code flow (sendVerificationOTP, verifyEmailOTP) โ this is what powers a code-entry verification page instead of a magic-link-only flow.nextCookies() needs to be the last plugin in the array โ it patches Better Auth's response handling so cookies are correctly set from Server Actions in the Next.js App Router.github for whichever social providers you actually use, or drop socialProviders entirely if you're doing email/password only for now.lib/auth/auth-client.ts)This is what your client components ("use client" forms) use to trigger social sign-in, check session state, and so on.
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;Add NEXT_PUBLIC_BETTER_AUTH_URL to your .env so the client bundle can read it (the NEXT_PUBLIC_ prefix is required for any env var read in browser code):
NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000"Better Auth needs a catch-all route to handle sign up, sign in, callbacks, and session endpoints.
mkdir -p app/api/auth/[...all]
touch app/api/auth/[...all]/route.tsimport { auth } from "@/lib/auth/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);That's it for this file โ toNextJsHandler maps every Better Auth endpoint (/api/auth/sign-up/email, /api/auth/sign-in/email, /api/auth/verify-email, etc.) onto this one route automatically.
Rather than calling authClient directly from your form for sign up (since you likely want to also send a verification code and read the created user back out), wrap the flow in a server action:
"use server";
import { auth } from "@/lib/auth/auth";
import db from "@/prisma/prisma-instance";
import { APIError } from "better-auth/api";
import { RegisterUserSchemaTypes } from "@/schemas/user-authentication.schema";
export async function registerUser(userRegisterDetails: RegisterUserSchemaTypes) {
try {
await auth.api.signUpEmail({
body: {
email: userRegisterDetails.email,
password: userRegisterDetails.password,
name: `${userRegisterDetails.firstName} ${userRegisterDetails.lastName}`,
firstName: userRegisterDetails.firstName,
lastName: userRegisterDetails.lastName,
phone: userRegisterDetails.phone,
},
});
await auth.api.sendVerificationOTP({
body: { email: userRegisterDetails.email, type: "email-verification" },
});
const createdUser = await db.user.findUnique({
where: { email: userRegisterDetails.email },
select: { id: true, email: true, firstName: true, lastName: true, phone: true },
});
if (!createdUser) throw new Error("User was created but could not be retrieved");
return { success: true, data: createdUser, error: null };
} catch (error) {
if (error instanceof APIError && error.status === "UNPROCESSABLE_ENTITY") {
return { success: false, data: null, error: "Email or phone already in use", status: error.status };
}
console.error("Registration error:", error);
return { success: false, data: null, error: "Something went wrong. Please try again." };
}
}Notice the error handling here doesn't try to guess "email vs phone" from the generic
"Failed to create user"message Better Auth throws for any underlying database error โ that string isn't specific enough to tell the two apart reliably. If you need to distinguish which field caused a duplicate error, check for the existing record yourself withdb.user.findFirst()before callingsignUpEmail, rather than parsing the error message after the fact.
Both sendResetPassword (Step 5) and sendVerificationOTP (Step 5) need an actual email-sending function behind them. If you're using Resend:
pnpm add resendimport { Resend } from "resend";
import VerificationEmailTemplate from "@/emails/verification-email";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendVerificationEmail({ to, code }: { to: string; code: string }) {
await resend.emails.send({
from: "CRAFT UI <no-reply@yourdomain.com>",
to,
subject: `Your verification code is ${code}`,
react: VerificationEmailTemplate({ userEmail: to, verificationCode: code }),
});
}Add your Resend API key to .env:
RESEND_API_KEY="re_your_api_key_here"On the client side, your verification page collects the 6-digit code and calls a server action wrapping auth.api.verifyEmailOTP:
export async function verifyEmail(data: { email: string; code: string }) {
try {
const result = await auth.api.verifyEmailOTP({
body: { email: data.email, otp: data.code },
});
return { success: true, data: result, error: null };
} catch (error) {
if (error instanceof APIError) {
return { success: false, data: null, error: error.message, status: error.status };
}
return { success: false, data: null, error: "Failed to verify email." };
}
}If you're passing the email in through a dynamic route segment (e.g.
/verify/[email]/page.tsx), remember todecodeURIComponent()the param before using it anywhere โ the browser URL-encodes the@in an email address (@โ%40), and Better Auth will reject or fail to match an encoded email against its stored records.
export async function SigninUser(userSigninDetails: SignInUserSchemaTypes) {
try {
const result = await auth.api.signInEmail({
body: {
email: userSigninDetails.email,
password: userSigninDetails.password,
rememberMe: userSigninDetails.rememberMe,
},
});
return { success: true, data: { user: result.user }, error: null };
} catch (error) {
if (error instanceof APIError && error.status === "UNAUTHORIZED") {
return { success: false, data: null, error: error.message, status: error.status };
}
return { success: false, data: null, error: "Sign in failed." };
}
}Keep your schema file as the single source of truth for both client-side validation and TypeScript types:
import z from "zod";
export const RegisterUserSchema = z.object({
firstName: z.string().min(2, "First name must be at least 2 characters"),
lastName: z.string().min(2, "Last name must be at least 2 characters"),
image: z.string().optional(),
email: z.string().email("Please enter a valid email address"),
phone: z.string().min(10, "Please enter a valid phone number"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
confirmPassword: z.string().min(1, "Please confirm your password"),
terms: z.boolean().refine((val) => val === true, {
message: "You must agree to the Terms and Conditions to continue",
}),
});
export type RegisterUserSchemaTypes = z.infer<typeof RegisterUserSchema>;
export const SignInSchema = z.object({
email: z.string().email({ message: "Please enter a valid email address" }),
password: z.string().min(8, { message: "Password must be at least 8 characters" }),
rememberMe: z.boolean(),
});
export type SignInUserSchemaTypes = z.infer<typeof SignInSchema>;
export const verifyEmailSchema = z.object({
code: z.string().length(6, "Code must be 6 digits"),
});
export type VerifyUserSchemaTypes = z.infer<typeof verifyEmailSchema>;Wire it into your form with zodResolver and Controller for any custom (non-native-<input>) fields like checkboxes:
"use client";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { RegisterUserSchema, RegisterUserSchemaTypes } from "@/schemas/user-authentication.schema";
import { registerUser } from "@/actions/user-authentication";
export default function SignUpForm() {
const form = useForm<RegisterUserSchemaTypes>({
resolver: zodResolver(RegisterUserSchema),
defaultValues: {
firstName: "",
lastName: "",
email: "",
phone: "",
password: "",
confirmPassword: "",
terms: false,
},
});
async function onSubmit(data: RegisterUserSchemaTypes) {
const response = await registerUser(data);
// handle response.success / response.error here
}
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
<input {...form.register("firstName")} placeholder="First name" />
<input {...form.register("lastName")} placeholder="Last name" />
<input {...form.register("email")} placeholder="Email" />
<input {...form.register("phone")} placeholder="Phone" />
<input {...form.register("password")} type="password" placeholder="Password" />
<Controller
name="terms"
control={form.control}
render={({ field }) => (
<input
type="checkbox"
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<button type="submit">Sign Up</button>
</form>
);
}Unknown argument 'firstName' (or phone, lastName)The field exists in schema.prisma but Better Auth isn't forwarding it. Check two things: (1) the field is declared under user.additionalFields in auth.ts (Step 5), and (2) your Prisma client has been regenerated since the schema change:
pnpm prisma generate
rm -rf .next
pnpm devBetter Auth wraps most create() failures in a generic APIError with the message "Failed to create user" โ it doesn't tell you why the create failed. If your error handling branches on that exact string to guess "must be a duplicate," it will misreport unrelated errors (like a schema mismatch) as duplicate-field errors. Check for existing records yourself before calling signUpEmail instead of inferring the reason from the message text.
Almost always a URL-encoding issue โ the email passed to verifyEmailOTP still contains %40 instead of @ because it was read straight from a route param without decodeURIComponent(). Decode it before calling any Better Auth method.
Drift detected when running prisma migrate devThis means your database already has tables that aren't reflected in your migration history โ usually because it was set up with db push at some point. For a local dev database with disposable data, the fastest fix is:
pnpm prisma migrate resetThis wipes the database and replays every migration from scratch. Don't run this against a database with real data you need to keep.
better-authBETTER_AUTH_SECRET and set BETTER_AUTH_URL in .envUser, Session, Account, Verification models to schema.prismaprisma migrate dev and prisma generatelib/auth/auth.ts โ Prisma adapter, additionalFields, emailAndPassword, emailOTP plugin, nextCookies() lastlib/auth/auth-client.ts for client-side hooksapp/api/auth/[...all]/route.tsController for non-native inputsThe clean mental model is:
schema.prisma (User + Session + Account + Verification, plus your own fields)
-> auth.ts (Prisma adapter + additionalFields + plugins)
-> app/api/auth/[...all]/route.ts (exposes every Better Auth endpoint)
-> server actions (signUpEmail, signInEmail, verifyEmailOTP, ...)
-> your forms (Zod + React Hook Form)The biggest advice is simple:
Any custom field you add to
Userinschema.prismaalso needs a matching entry inadditionalFieldsinsideauth.tsโ the schema alone isn't enough. And whenever you changeschema.prisma, re-runprisma generateand clear.nextbefore assuming the error is something more complicated.