See all blogs

July 13, 2026

Set Up Better Auth with Prisma ORM and Postgres in Next.js

Set Up Better Auth with Prisma ORM and Postgres in Next.jsSet Up Better Auth with Prisma ORM and Postgres in Next.js

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-pg driver 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.

backstory

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.prisma file with the four required auth models (User, Session, Account, Verification), an auth.ts config 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.


what we are building

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)
 
.env

A 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 email

prerequisites

Before installing Better Auth, make sure you already have:

  • Node.js 18 or newer
  • A Next.js 15+ project using the App Router
  • TypeScript configured
  • Prisma 7 already installed and connected to a Postgres database (Neon or Prisma Postgres), using the @prisma/adapter-pg driver adapter โ€” see the Prisma 7 setup guide if you haven't done this yet
  • A prisma/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.


step 1: install Better Auth

pnpm add better-auth

That'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.


step 2: generate a Better Auth secret

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 secret

Copy 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.


step 3: add the required models to your Prisma schema

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.
  • If you're using the Prisma driver adapter (@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 generate

If 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.


step 4: create your Prisma client singleton

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.


step 5: configure Better Auth (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.
  • Swap github for whichever social providers you actually use, or drop socialProviders entirely if you're doing email/password only for now.

step 6: create the Better Auth client hooks (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"

step 7: expose the auth API route

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.ts
import { 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.


step 8: call Better Auth from a server action

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 with db.user.findFirst() before calling signUpEmail, rather than parsing the error message after the fact.


step 9: wire up email sending (Resend example)

Both sendResetPassword (Step 5) and sendVerificationOTP (Step 5) need an actual email-sending function behind them. If you're using Resend:

pnpm add resend
import { 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"

step 10: verify email with the OTP code

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 to decodeURIComponent() 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.


step 11: sign in

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." };
  }
}

step 12: validate forms with Zod + React Hook Form

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>
  );
}

common errors and fixes

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 dev

"User with this phone number already exists" but the real error is something else

Better 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.

Verification code always says "invalid or expired"

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 dev

This 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 reset

This wipes the database and replays every migration from scratch. Don't run this against a database with real data you need to keep.


final checklist

  • Install better-auth
  • Generate a BETTER_AUTH_SECRET and set BETTER_AUTH_URL in .env
  • Add User, Session, Account, Verification models to schema.prisma
  • Run prisma migrate dev and prisma generate
  • Create your Prisma client singleton with the driver adapter
  • Configure lib/auth/auth.ts โ€” Prisma adapter, additionalFields, emailAndPassword, emailOTP plugin, nextCookies() last
  • Create lib/auth/auth-client.ts for client-side hooks
  • Add the catch-all route at app/api/auth/[...all]/route.ts
  • Wire up server actions for sign up, sign in, and verification
  • Connect an email provider (Resend or similar) for OTP and password-reset emails
  • Validate forms with Zod + React Hook Form, using Controller for non-native inputs

conclusion

The 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 User in schema.prisma also needs a matching entry in additionalFields inside auth.ts โ€” the schema alone isn't enough. And whenever you change schema.prisma, re-run prisma generate and clear .next before assuming the error is something more complicated.

resources

  • Better Auth documentation
  • Better Auth Prisma adapter
  • Prisma + Better Auth guide
  • jb.desishub.com โ€” Complete Better Auth + Prisma integration guide
  • Related: Set Up Prisma 7 in Next.js 15+ with Neon DB or Prisma Postgres

See all blogs