Better Auth + Prisma: Secure Auth in Next.js App Router

I’ve tried a dozen authentication setups in modern React apps.
Some felt like magic. Others… like pain.
Here’s how I finally set up auth right — using Better Auth, Prisma, and the App Router in Next.js, with clean form UIs viashadcn/ui
.
🧱 Why Better Auth?
I was tired of overcomplicated adapters, magic cookies, and digging through outdated docs.
Better Auth felt refreshingly focused:
- Email + password out of the box
- Prisma adapter included
- Built for App Router, not shoehorned in
So I spun up a clean Next.js project and got to work.
⚙️ The Stack
- ✅ Next.js 14+ with App Router
- ✅ Prisma ORM with PostgreSQL
- ✅ Better Auth
- ✅ shadcn/ui for forms
- ✅ Tailwind CSS
🛠️ Project Setup
npx create-next-app@latest betterauth-prisma-starter
Choose:
- TypeScript: Yes
- Tailwind CSS: Yes
- App Router: Yes
- Inside
/src
: Yes - Eslint: Yes
- Turbopack: Yes
- Customize imports: No
For installation, you can follow the steps on Next.js or on Shadcn
Now navigate into the project :
cd betterauth-prisma-starter
Then install your auth stack:
npm install prisma --save-dev
npm install @prisma/client @prisma/extension-accelerate
npx prisma init
To generate the prisma client, run this command:
npx prisma generate
Now, install better-auth,
npm install better-auth
Generate a secure secret for your authentification
npx @better-auth/cli@latest secret
🧪 Configure Prisma + Better Auth
Edit your .env
:
BETTER_AUTH_SECRET=generated-secret
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=your-prisma-db-url
🔧 Create a prisma.ts
file
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;
🔐 Auth config
Create a auth.ts
file in the lib
directory and add this code
import { prisma } from "@/db/prisma";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
export const auth = betterAuth({
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
github: {
clientId: process.env.GITHUB_CLIENT_ID as string,
clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
},
},
emailAndPassword: {
enabled: true,
},
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
plugins: [nextCookies()],
});
In order to use and modify cookies while using server actions, you must add nextCookies()
to the configuration of the auth.js file.
This is a very useful plugin when using the signInEmail or signUpEmail functions during authentication. A set Cookies call will be launched automatically in your code.
"use server";
import { auth } from "@/lib/auth";
export const signIn = 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.",
};
}
};
import { signIn } from "@/server/users";
import { toast } from "sonner";
...
async function onSubmit(values: z.infer<typeof formSchema>) {
const { success, message } = await signIn(values.email, values.password);
if (success) {
toast.success(message as string);
router.push("/dashboard");
} else {
toast.error(message as string);
}
}
...
After that, create the schema.prisma
file, which is the database model management file.
ℹ️ For better-auth to work and interact with Prisma, the User, Session, Account, and Verification models must exist and be properly configured in the schema.prisma
file. You can use this code for basic use.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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")
}
The file configuration is ready. Now run the following commands to migrate the prisma schema with the Better-auth models
Run:
npx @better-auth/cli generate
npx prisma migrate dev --name init-migration
This code manages password authentication with the option emailAndPassword: true. Here, we have also integrated social providers to connect with Google and Github.
To generate access to GitHub authentication, follow these steps: -Go to your profile, then at the bottom of the sidebar, -There is the Developer settings option. -Enter and select OAuth Apps, -Fill out the form
🧩 Add Catch-All Route
Create a route file in your application and place it at src/app/api/auth/[...all]/route.ts
. It's pretty much the same as the Auth.js configuration.
If you want to know how to switch from Authjs to Better auth, you can see it clearly here.
Integration is easy, but I'll show you how to do it more quickly so you don't get lost. Here, we are using the Next.js App Router. You can find more information about integrating Better-auth and next.js.
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);
Client auth hooks:
For the client interface, create the src/lib/auth-client.ts file according to your project structure.
import { createAuthClient } from "better-auth/react";
export const { signIn, signUp, signOut, useSession } = createAuthClient();
To do this, you can use client-mode authentication. Network authentication management will be simpler. Logging out will also be simplified.
const signInWithGoogle = async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
};
🧾 Sign-Up Form (with Shadcn UI)
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { signIn } from "@/server/users";
import { z } from "zod";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export function LoginForm({className, ...props}: React.ComponentProps<"div">) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});
const signInWithGoogle = async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
};
async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);
const { success, message } = await signIn(values.email, values.password);
if (success) {
toast.success(message as string);
router.push("/dashboard");
} else {
toast.error(message as string);
}
setIsLoading(false);
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Login with your Google account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="grid gap-6">
<div className="flex flex-col gap-4">
<Button
variant="outline"
className="w-full"
type="button"
onClick={signInWithGoogle}
>
<GoogleIcon />
Login with Google
</Button>
</div>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
Or continue with
</span>
</div>
<div className="grid gap-6">
<div className="grid gap-3">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="m@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-3">
<div className="flex flex-col gap-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="********"
{...field}
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Login"
)}
</Button>
</div>
<div className="text-center text-sm">
Don't have an account?{" "}
<Link href="/signup" className="underline underline-offset-4">
Sign up
</Link>
</div>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}
👋 If this helped…
👉 Subscribe for updates on auth, App Router, and production-ready stacks.
💬 Share this if your team is migrating from Auth.js or building a new stack.