Next.js Better Auth Starter
A production-ready authentication template built with Next.js 16, better-auth, Prisma, and Neon. It ships with email/password, magic links, Google and GitHub OAuth, role-based access control, and transactional emails — all wired up and ready to customize.
This page explains how the template is structured, how each feature works, and how to adapt it to your own project.
Next.js 16
Framework
better-auth
Authentication
Prisma 7
ORM
Neon
Database
Resend
Emails
Zod
Validation
Tailwind CSS
Styling
React Hook Form
Forms
Getting Started
1. Clone and install
git clone https://github.com/hamilton-k-dev/nextjs-better-auth-stater
cd nextjs-better-auth-stater
npm install2. Environment variables
Copy the example file and fill in your values:
cp .env.example .env| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_APP_URL | Local only | Set to http://localhost:3000 locally. On Vercel, auto-derived from VERCEL_URL via next.config.ts. |
BETTER_AUTH_URL | Local only | Set to http://localhost:3000 locally. On Vercel, auto-derived from VERCEL_URL in auth.ts. |
BETTER_AUTH_SECRET | Yes | Random 32-byte secret: openssl rand -base64 32 |
DATABASE_URL | Yes | Neon pooled connection string |
DIRECT_URL | Yes | Neon direct URL — used by prisma migrate |
RESEND_API_KEY | No* | Resend API key. Not needed in demo mode. |
EMAIL_FROM | No* | Sender address. Not needed in demo mode. |
GOOGLE_CLIENT_ID | No* | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | No* | Google OAuth client secret |
GITHUB_CLIENT_ID | No* | GitHub OAuth app client ID |
GITHUB_CLIENT_SECRET | No* | GitHub OAuth app client secret |
DEMO_MODE | Demo only | Set to "true" to skip email sending server-side |
NEXT_PUBLIC_DEMO_MODE | Demo only | Set to "true" to show email links in the UI |
3. Database setup
npx prisma migrate dev --name init
npx prisma generate4. Run locally
npm run devVisit http://localhost:3000.
Project Structure
src/
├── app/
│ ├── page.tsx # Landing page
│ ├── doc/page.tsx # This documentation page
│ ├── (auth)/
│ │ ├── login/page.tsx # Sign in — password, magic link, OAuth
│ │ ├── register/page.tsx # Create account
│ │ └── reset-password/page.tsx # Forgot & reset password
│ ├── (protected)/
│ │ └── dashboard/
│ │ ├── page.tsx # User dashboard
│ │ └── admin/page.tsx # Admin panel (ADMIN role only)
│ └── api/
│ ├── auth/[...all]/route.ts # better-auth handler
│ ├── admin/users/route.ts # Role management API
│ └── demo/inbox/route.ts # Demo mode email link API
├── components/
│ └── demo-inbox.tsx # Demo mode email card
├── lib/
│ ├── auth.ts # better-auth server config
│ ├── auth-client.ts # better-auth browser client
│ ├── db.ts # Prisma singleton (Neon adapter)
│ └── validations/auth.ts # Zod schemas
└── proxy.ts # Route protection (Next.js 16)Authentication
All auth logic lives in src/lib/auth.ts (server config) and src/lib/auth-client.ts (browser client). The better-auth API is mounted at /api/auth/[...all].
Email & Password
Enabled by default with required email verification. After sign-up, the user receives a verification email before they can sign in. Password reset sends a time-limited link to the user's email.
// auth.ts
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
}Magic Links
Powered by the magicLink plugin. The user enters their email and receives a one-click sign-in link valid for 15 minutes.
// auth.ts
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => { ... }
})
]OAuth — Google & GitHub
Configure both providers in auth.ts via environment variables. Redirect URIs to register in each provider's console:
# Google — console.cloud.google.com
http://localhost:3000/api/auth/callback/google
https://yourdomain.com/api/auth/callback/google
# GitHub — github.com/settings/developers
http://localhost:3000/api/auth/callback/github
https://yourdomain.com/api/auth/callback/githubTo add more OAuth providers, install the provider plugin from better-auth and add it to the socialProviders block in auth.ts.
Email delivery
Emails are sent via Resend. The three transactional emails — email verification, password reset, and magic link — are all defined as inline HTML in auth.ts. Customize the HTML to match your brand.
Route Protection
Next.js 16 replaces middleware.ts with proxy.ts. This file runs on every request and controls who can access what.
// src/proxy.ts
// Accessible without a session
const PUBLIC_ROUTES = ["/", "/login", "/register", "/reset-password", "/verify-email", "/doc", "/api/demo"];
// Redirect authenticated users away (back to /dashboard)
const AUTH_ROUTES = ["/login", "/register"];
// Require ADMIN role — non-admins redirected to /dashboard
const ADMIN_ROUTES = ["/dashboard/admin"];| Scenario | Result |
|---|---|
| Unauthenticated user visits a protected route | Redirect to /login?callbackUrl=… |
Authenticated user visits /login or /register | Redirect to /dashboard |
Authenticated USER visits /dashboard/admin | Redirect to /dashboard |
Authenticated ADMIN visits /dashboard/admin | Allowed |
Adding a protected route
Any route not listed in PUBLIC_ROUTES is automatically protected — no extra config needed. Just create the page.
Adding a public route
Add the path to PUBLIC_ROUTES in src/proxy.ts:
const PUBLIC_ROUTES = [
...,
"/pricing", // add your public route here
];Adding an admin-only section
const ADMIN_ROUTES = [
"/dashboard/admin",
"/dashboard/reports", // add your admin route here
];Role-Based Access Control
Users have a role field with two values: USER (default) and ADMIN. The role is stored in the database and exposed on the session object.
Protecting a server page
import { requireAuth } from "@/actions/auth";
export default async function Page() {
const session = await requireAuth(); // redirects to /login if not authenticated
return <div>Hello {session.user.name}</div>;
}Protecting an admin-only page
import { requireAdmin } from "@/actions/auth";
export default async function AdminPage() {
const session = await requireAdmin(); // redirects to /dashboard if not ADMIN
return <div>Admin only</div>;
}Reading the role on the client
"use client";
import { useSession } from "@/lib/auth-client";
export function RoleBadge() {
const { data: session } = useSession();
return <span>{session?.user.role}</span>;
}Promoting a user to admin
Use the built-in admin panel at /dashboard/admin to promote or revoke admin access. Alternatively, use Prisma Studio:
npx prisma studioAdding a new role
Update the Prisma schema enum, run a migration, and add the new role checks wherever needed:
// prisma/schema.prisma
enum Role {
USER
ADMIN
MODERATOR // add your role here
}
// then run:
// npx prisma migrate dev --name add-moderator-roleDemo Mode
Demo mode disables Resend and surfaces email links directly in the UI as an amber card — no inbox needed. Designed for public demos or staging environments where visitors shouldn't need a real email address.
Enable it
Set both variables — one is read server-side, the other is baked into the client bundle at build time:
DEMO_MODE="true"
NEXT_PUBLIC_DEMO_MODE="true"RESEND_API_KEY and EMAIL_FROM are not required in demo mode.
How it works
User triggers email action (sign up, magic link, reset)
→ Server saves the generated URL to demo_inbox table
→ Page renders DemoEmailCard (src/components/demo-inbox.tsx)
→ Card polls GET /api/demo/inbox?email=…&type=…
→ User clicks the action button → token handled by better-authRequired migration
The demo_inbox table must exist before demo mode works:
npx prisma migrate dev --name add-demo-inbox # local
npx prisma migrate deploy # productionCustomization
Customize email templates
All three email templates (verification, password reset, magic link) are inline HTML strings in src/lib/auth.ts. Replace them with your own HTML or use a React email renderer:
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: process.env.EMAIL_FROM!,
to: user.email,
subject: "Verify your email",
html: `<p>Click <a href="${url}">here</a> to verify.</p>`,
// or: react: <VerificationEmail url={url} />
});
}Extend the user model
Add fields to the Prisma schema and expose them via better-auth's additionalFields:
// prisma/schema.prisma
model User {
...
bio String? // add your field
}
// src/lib/auth.ts
user: {
additionalFields: {
bio: { type: "string", required: false },
},
}Then run npx prisma migrate dev --name add-user-bio.
Add an OAuth provider
// src/lib/auth.ts
socialProviders: {
google: { ... },
github: { ... },
discord: { // example: Discord
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
}Add the matching env vars and register the redirect URI in the provider's developer console.
Change the post-login redirect
Update callbackURL in the auth pages and the proxy redirect target:
// src/app/(auth)/register/page.tsx
await signUp.email({ ..., callbackURL: "/onboarding" });
// src/proxy.ts — for AUTH_ROUTES redirect
return NextResponse.redirect(new URL("/onboarding", request.url));Deployment
Vercel (recommended)
Push your repo to GitHub, import it in Vercel, and set these environment variables before deploying:
| Variable | Value |
|---|---|
NEXT_PUBLIC_APP_URL | Not needed — auto-derived from VERCEL_URL |
BETTER_AUTH_URL | Not needed — auto-derived from VERCEL_URL |
BETTER_AUTH_SECRET | generate with openssl rand -base64 32 |
DATABASE_URL | Neon pooled URL |
DIRECT_URL | Neon direct URL |
RESEND_API_KEY | from resend.com (not needed in demo mode) |
EMAIL_FROM | no-reply@yourdomain.com |
GOOGLE_CLIENT_ID / SECRET | from Google Console |
GITHUB_CLIENT_ID / SECRET | from GitHub OAuth App |
NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL are not needed on Vercel. Both are auto-derived from VERCEL_PROJECT_PRODUCTION_URL, Vercel's built-in variable for the stable production domain. Do not use VERCEL_URL — it changes on every deployment and causes CORS errors when users visit the production alias.Update OAuth redirect URIs
# Google Console
https://yourdomain.com/api/auth/callback/google
# GitHub OAuth App
https://yourdomain.com/api/auth/callback/githubRun database migrations on production
npx prisma migrate deployKey scripts
npm run dev # Start development server
npm run build # Production build
npm run typecheck # TypeScript check (no emit)
npm run migrate # Run prisma migrate deploy
npm run studio # Open Prisma Studio
npm run seed # Seed the database with demo users