Overview

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

Setup

Getting Started

1. Clone and install

git clone https://github.com/hamilton-k-dev/nextjs-better-auth-stater
cd nextjs-better-auth-stater
npm install

2. Environment variables

Copy the example file and fill in your values:

cp .env.example .env
VariableRequiredDescription
NEXT_PUBLIC_APP_URLLocal onlySet to http://localhost:3000 locally. On Vercel, auto-derived from VERCEL_URL via next.config.ts.
BETTER_AUTH_URLLocal onlySet to http://localhost:3000 locally. On Vercel, auto-derived from VERCEL_URL in auth.ts.
BETTER_AUTH_SECRETYesRandom 32-byte secret: openssl rand -base64 32
DATABASE_URLYesNeon pooled connection string
DIRECT_URLYesNeon direct URL — used by prisma migrate
RESEND_API_KEYNo*Resend API key. Not needed in demo mode.
EMAIL_FROMNo*Sender address. Not needed in demo mode.
GOOGLE_CLIENT_IDNo*Google OAuth client ID
GOOGLE_CLIENT_SECRETNo*Google OAuth client secret
GITHUB_CLIENT_IDNo*GitHub OAuth app client ID
GITHUB_CLIENT_SECRETNo*GitHub OAuth app client secret
DEMO_MODEDemo onlySet to "true" to skip email sending server-side
NEXT_PUBLIC_DEMO_MODEDemo onlySet to "true" to show email links in the UI

3. Database setup

npx prisma migrate dev --name init
npx prisma generate

4. Run locally

npm run dev

Visit http://localhost:3000.

Architecture

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)
Auth

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/github

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

Security

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"];
ScenarioResult
Unauthenticated user visits a protected routeRedirect to /login?callbackUrl=…
Authenticated user visits /login or /registerRedirect to /dashboard
Authenticated USER visits /dashboard/adminRedirect to /dashboard
Authenticated ADMIN visits /dashboard/adminAllowed

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
];
Access Control

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 studio

Adding 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-role
Demo

Demo 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-auth

Required migration

The demo_inbox table must exist before demo mode works:

npx prisma migrate dev --name add-demo-inbox   # local
npx prisma migrate deploy                       # production
Customize

Customization

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

Deployment

Vercel (recommended)

Push your repo to GitHub, import it in Vercel, and set these environment variables before deploying:

VariableValue
NEXT_PUBLIC_APP_URLNot needed — auto-derived from VERCEL_URL
BETTER_AUTH_URLNot needed — auto-derived from VERCEL_URL
BETTER_AUTH_SECRETgenerate with openssl rand -base64 32
DATABASE_URLNeon pooled URL
DIRECT_URLNeon direct URL
RESEND_API_KEYfrom resend.com (not needed in demo mode)
EMAIL_FROMno-reply@yourdomain.com
GOOGLE_CLIENT_ID / SECRETfrom Google Console
GITHUB_CLIENT_ID / SECRETfrom 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/github

Run database migrations on production

npx prisma migrate deploy

Key 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