Skip to main content

How to use Prisma ORM with Better-Auth and Next.js

25 min

Introduction

Better-Auth is a modern, open-source authentication solution for web applications. It's built with TypeScript, React, and Prisma to provide a simple and extensible auth experience.

In this guide, you'll wire Better-Auth into a brand-new Next.js app and persist users in a Prisma Postgres database. You can find a complete example of this guide on GitHub.

Prerequisites

  • Node.js 18+
  • Basic familiarity with Next.js App Router and Prisma

1. Set up your project

Create a new Next.js application:

npx create-next-app@latest betterauth-nextjs-prisma

It will prompt you to customize your setup. Choose the defaults:

info
  • Would you like to use TypeScript? Yes
  • Would you like to use ESLint? Yes
  • Would you like to use Tailwind CSS? Yes
  • Would you like your code inside a src/ directory? Yes
  • Would you like to use App Router? Yes
  • Would you like to use Turbopack? Yes
  • Would you like to customize the import alias (@/* by default)? No

Navigate to the project directory:

cd betterauth-nextjs-prisma

These selections will create a modern Next.js project with TypeScript for type safety, ESLint for code quality, and Tailwind CSS for styling. Using the src/ directory and the App Router are common conventions for new Next.js applications.

2. Set up Prisma

Next, you'll add Prisma to your project to manage your database.

2.1. Install Prisma and dependencies

Install the necessary Prisma packages. The dependencies differ slightly depending on whether you use Prisma Postgres with Accelerate or another database.

npm install prisma --save-dev
npm install @prisma/extension-accelerate @prisma/client

Once installed, initialize Prisma in your project:

npx prisma init --db --output ../src/generated/prisma
info

You'll need to answer a few questions while setting up your Prisma Postgres database. Select the region closest to your location and a memorable name for your database like "My Better-Auth Project"

This will create:

  • A prisma directory with a schema.prisma file.
  • A Prisma Postgres database.
  • A .env file containing the DATABASE_URL at the project root.
  • An output directory for the generated Prisma Client as better-auth/generated/prisma.

2.2. Configure the Prisma client generator

Run the following command to create the database tables and generate the Prisma Client:

npx prisma generate

2.3. Set up a global Prisma client

Create a /lib directory and a prisma.ts file inside it. This file will be used to create and export your Prisma Client instance.

mkdir -p src/lib
touch src/lib/prisma.ts

Set up the Prisma client like this:

src/lib/prisma.ts
import { PrismaClient } from "@/generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";

const globalForPrisma = global as unknown as {
prisma: PrismaClient;
};

const prisma =
globalForPrisma.prisma || new PrismaClient().$extends(withAccelerate());

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

export default prisma;
warning

We recommend using a connection pooler (like Prisma Accelerate) to manage database connections efficiently.

If you choose not to use one, avoid instantiating PrismaClient globally in long-lived environments. Instead, create and dispose of the client per request to prevent exhausting your database connections.

3. Set up Better-Auth

Now it's time to integrate Better-Auth for authentication.

3.1. Install and configure Better-Auth

First, install the Better-Auth core package:

npm install better-auth

Next, generate a secure secret that Better-Auth will use to sign authentication tokens. This ensures your tokens cannot be messed with.

npx @better-auth/cli@latest secret

Copy the generated secret and add it, along with your application's URL, to your .env file:

.env
# Better-Auth
BETTER_AUTH_SECRET=your-generated-secret
BETTER_AUTH_URL=http://localhost:3000

# Prisma
DATABASE_URL="your-database-url"

Now, create a configuration file for Better-Auth at src/lib/auth.ts:

touch src/lib/auth.ts

In this file, you'll configure Better-Auth to use the Prisma adapter, which allows it to persist user and session data in your database. You will also enable email and password authentication.

src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import prisma from '@/lib/prisma'

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
})

Better-Auth also supports other sign-in methods like social logins (Google, GitHub, etc.), which you can explore in their documentation.

src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import prisma from '@/lib/prisma'

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
},
})
info

If your application runs on a port other than 3000, you must add it to the trustedOrigins in your auth.ts configuration to avoid CORS errors during authentication requests.

src/lib/auth.ts
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import prisma from '@/lib/prisma'

export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
},
trustedOrigins: ['http://localhost:3001'],
})

3.2. Add Better-Auth models to your schema

Better-Auth provides a CLI command to automatically add the necessary authentication models (User, Session, Account, and Verification) to your schema.prisma file.

Run the following command:

npx @better-auth/cli generate
note

It will ask for confirmation to overwrite your existing Prisma schema. Select y.

This will add the following models:

model User {
id String @id
name String
email String
emailVerified Boolean
image String?
createdAt DateTime
updatedAt DateTime
sessions Session[]
accounts Account[]

@@unique([email])
@@map("user")
}

model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime
updatedAt DateTime
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@unique([token])
@@map("session")
}

model Account {
id String @id
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime
updatedAt DateTime

@@map("account")
}

model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime?
updatedAt DateTime?

@@map("verification")
}

3.3. Migrate the database

With the new models in your schema, you need to update your database. Run a migration to create the corresponding tables:

npx prisma migrate dev --name add-auth-models

4. Set up the API routes

Better-Auth needs an API endpoint to handle authentication requests like sign-in, sign-up, and sign-out. You'll create a catch-all API route in Next.js to handle all requests sent to /api/auth/[...all].

First, create the necessary directory and file:

mkdir -p "src/app/api/auth/[...all]"
touch "src/app/api/auth/[...all]/route.ts"

Add the following code to the newly created route.ts file. This code uses a helper from Better-Auth to create Next.js-compatible GET and POST request handlers.

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

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

Next, you'll need a client-side utility to interact with these endpoints from your React components. Create a new file src/lib/auth-client.ts:

touch src/lib/auth-client.ts

Add the following code, which creates the React hooks and functions you'll use in your UI:

import { createAuthClient } from 'better-auth/react'

export const { signIn, signUp, signOut, useSession } = createAuthClient()

5. Set up your pages

Now, let's build the user interface for authentication. Create the pages for signing up, signing in, and a protected dashboard:

mkdir -p src/app/{sign-up,sign-in,dashboard}
touch src/app/{sign-up,sign-in,dashboard}/page.tsx

5.1. Sign up page

First, create the basic SignUpPage component in src/app/sign-up/page.tsx. This sets up the main container and a title for your page.

src/app/sign-up/page.tsx
"use client";

export default function SignUpPage() {
return (
<main className="max-w-md mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign Up</h1>
</main>
);
}

Next, import the necessary hooks from React and Next.js to manage state and navigation. Initialize the router and a state variable to hold any potential error messages.

src/app/sign-up/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function SignUpPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

return (
<main className="max-w-md mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign Up</h1>
</main>
);
}

Now, import the signUp function from your Better-Auth client and add the handleSubmit function. This function is triggered on form submission and calls the signUp.email method provided by Better-Auth, passing the user's name, email, and password.

src/app/sign-up/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
//add-next-lin
import { signUp } from "@/lib/auth-client";

export default function SignUpPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signUp.email({
name: formData.get("name") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign Up</h1>
</main>
);
}

To inform the user of any issues, add an element that conditionally renders when the error state is not null.

src/app/sign-up/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";

export default function SignUpPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signUp.email({
name: formData.get("name") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign Up</h1>

{error && <p className="text-red-500">{error}</p>}
</main>
);
}

Finally, add the HTML form with input fields for the user's name, email, and password, and a submit button.

src/app/sign-up/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";

export default function SignUpPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signUp.email({
name: formData.get("name") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign Up</h1>

{error && <p className="text-red-500">{error}</p>}

<form onSubmit={handleSubmit} className="space-y-4">
<input
name="name"
placeholder="Full Name"
required
className="w-full rounded-md bg-neutral-900 border border-neutral-700 px-3 py-2"
/>
<input
name="email"
type="email"
placeholder="Email"
required
className="w-full rounded-md bg-neutral-900 border border-neutral-700 px-3 py-2"
/>
<input
name="password"
type="password"
placeholder="Password"
required
minLength={8}
className="w-full rounded-md bg-neutral-900 border border-neutral-700 px-3 py-2"
/>
<button
type="submit"
className="w-full bg-white text-black font-medium rounded-md px-4 py-2 hover:bg-gray-200"
>
Create Account
</button>
</form>
</main>
);
}

5.2. Sign in page

For the sign-in page, start with the basic structure in src/app/sign-in/page.tsx.

src/app/sign-in/page.tsx
"use client";

export default function SignInPage() {
return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign In</h1>
</main>
);
}

Now, add the state and router hooks, similar to the sign-up page.

src/app/sign-in/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function SignInPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign In</h1>
</main>
);
}

Add the handleSubmit function, this time importing and using the signIn.email method from Better-Auth.

src/app/sign-in/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";

export default function SignInPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign In</h1>
</main>
);
}

Add the conditional error message display.

src/app/sign-in/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";

export default function SignInPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign In</h1>

{error && <p className="text-red-500">{error}</p>}
</main>
);
}

Finally, add the form fields for email and password and a sign-in button.

src/app/sign-in/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";

export default function SignInPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);

const formData = new FormData(e.currentTarget);

const res = await signIn.email({
email: formData.get("email") as string,
password: formData.get("password") as string,
});

if (res.error) {
setError(res.error.message || "Something went wrong.");
} else {
router.push("/dashboard");
}
}

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Sign In</h1>

{error && <p className="text-red-500">{error}</p>}

<form onSubmit={handleSubmit} className="space-y-4">
<input
name="email"
type="email"
placeholder="Email"
required
className="w-full rounded-md bg-neutral-900 border border-neutral-700 px-3 py-2"
/>
<input
name="password"
type="password"
placeholder="Password"
required
className="w-full rounded-md bg-neutral-900 border border-neutral-700 px-3 py-2"
/>
<button
type="submit"
className="w-full bg-white text-black font-medium rounded-md px-4 py-2 hover:bg-gray-200"
>
Sign In
</button>
</form>
</main>
);
}

5.3. Dashboard page

This is the protected page for authenticated users. Start with the basic component in src/app/dashboard/page.tsx.

src/app/dashboard/page.tsx
"use client";

export default function DashboardPage() {
return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Dashboard</h1>
</main>
);
}

Import the useSession hook from your Better-Auth client. This hook is the key to managing authentication state on the client side. It provides the session data and a pending status.

src/app/dashboard/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useSession } from "@/lib/auth-client";

export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Dashboard</h1>
</main>
);
}

To protect this route, use a useEffect hook. This effect checks if the session has loaded (!isPending) and if there is no authenticated user (!session?.user). If both are true, it redirects the user to the sign-in page.

src/app/dashboard/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useSession } from "@/lib/auth-client";
import { useEffect } from "react";

export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();

//add-start: redirect if not signed in
useEffect(() => {
if (!isPending && !session?.user) {
router.push("/sign-in");
}
}, [isPending, session, router]);

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Dashboard</h1>
</main>
);
}

To provide a better user experience, add loading and redirecting states while the session is being verified.

src/app/dashboard/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useSession } from "@/lib/auth-client";
import { useEffect } from "react";

export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();

useEffect(() => {
if (!isPending && !session?.user) {
router.push("/sign-in");
}
}, [isPending, session, router]);

//add-start: loading and redirect states
if (isPending)
return <p className="text-center mt-8 text-white">Loading...</p>;
if (!session?.user)
return <p className="text-center mt-8 text-white">Redirecting...</p>;

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Dashboard</h1>
</main>
);
}

Finally, if the user is authenticated, display their name and email from the session object. Also, import the signOut function and add a button that calls it, allowing the user to log out.

src/app/dashboard/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { useSession, signOut } from "@/lib/auth-client";
import { useEffect } from "react";

export default function DashboardPage() {
const router = useRouter();
const { data: session, isPending } = useSession();

useEffect(() => {
if (!isPending && !session?.user) {
router.push("/sign-in");
}
}, [isPending, session, router]);

if (isPending)
return <p className="text-center mt-8 text-white">Loading...</p>;
if (!session?.user)
return <p className="text-center mt-8 text-white">Redirecting...</p>;

//add-start: destructure user from session
const { user } = session;

return (
<main className="max-w-md h-screen flex items-center justify-center flex-col mx-auto p-6 space-y-4 text-white">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p>Welcome, {user.name || "User"}!</p>
<p>Email: {user.email}</p>
{/* add-start: sign out button */}
<button
onClick={() => signOut()}
className="w-full bg-white text-black font-medium rounded-md px-4 py-2 hover:bg-gray-200"
>
Sign Out
</button>
</main>
);
}

5.4. Home page

Finally, update the home page to provide simple navigation to the sign-in and sign-up pages. Replace the contents of src/app/page.tsx with the following:

src/app/page.tsx
"use client";

import { useRouter } from "next/navigation";

export default function Home() {
const router = useRouter();

return (
<main className="flex items-center justify-center h-screen bg-neutral-950 text-white">
<div className="flex gap-4">
<button
onClick={() => router.push("/sign-up")}
className="bg-white text-black font-medium px-6 py-2 rounded-md hover:bg-gray-200">
Sign Up
</button>
<button
onClick={() => router.push("/sign-in")}
className="border border-white text-white font-medium px-6 py-2 rounded-md hover:bg-neutral-800">
Sign In
</button>
</div>
</main>
);
}

6. Test it out

Your application is now fully configured.

  1. Start the development server to test it:
npm run dev
  1. Navigate to http://localhost:3000 in your browser. You should see the home page with "Sign Up" and "Sign In" buttons.

  2. Click on Sign Up, create a new account, and you should be redirected to the dashboard. You can then sign out and sign back in.

  3. To view the user data directly in your database, you can use Prisma Studio.

npx prisma studio
  1. This will open a new tab in your browser where you can see the User, Session, and Account tables and their contents.
success

Congratulations! You now have a fully functional authentication system built with Better-Auth, Prisma, and Next.js.

Next steps

  • Add support for social login or magic links
  • Implement password reset and email verification
  • Add user profile and account management pages
  • Deploy to Vercel and secure your environment variables
  • Extend your Prisma schema with custom application models

Further reading


Stay connected with Prisma

Continue your Prisma journey by connecting with our active community. Stay informed, get involved, and collaborate with other developers:

We genuinely value your involvement and look forward to having you as part of our community!