ready-to-use-auth
A production-ready Next.js 16 authentication starter powered by Better Auth. Clone it, configure your DB and providers, and have a fully working auth layer in minutes — then build your app on top. Every part is customizable — database adapter, OAuth providers, UI shell, and redirect paths — without touching auth logic.
Stack: Next.js 16 App Router · Better Auth ^1.2 · shadcn/ui · Tailwind CSS v4 · TypeScript (strict, zero any)
Project Structure
proxy.ts # Route protection — replaces middleware.ts in Next.js 16+
app/
page.tsx # Landing page
sign-in/page.tsx # Sign-in route — RSC wrapper
sign-up/page.tsx # Sign-up route — RSC wrapper
dashboard/
page.tsx # Protected page — RSC with server-side session check
api/auth/[...all]/route.ts # Better Auth catch-all handler
components/auth/
providers.ts # All 10 OAuth providers — flip `enabled` to toggle
microsoft-icon.tsx # Custom SVG icon (template for new providers)
sign-out-button.tsx # Client component — only piece that needs the browser
sign-in/
sign-in-form.tsx # Pure UI — zero logic, receives typed props only
sign-in-logic.tsx # Controller — state, validation, auth calls
index.ts # Re-exports as <SignIn>
sign-up/
sign-up-form.tsx # Pure UI
sign-up-logic.tsx # Controller
index.ts # Re-exports as <SignUp>
lib/
auth.ts # Server-side Better Auth instance
auth-client.ts # Client-side auth client
db.ts # Database connection — 8 options, pick one
types/
auth.ts # All shared typesQuick Start
# 1. Install pnpm install # 2. Copy env template cp .env.example .env.local # 3. Fill in .env.local — minimum required: # BETTER_AUTH_SECRET=<run: openssl rand -base64 32> # NEXT_PUBLIC_APP_URL=http://localhost:3000 # DATABASE_URL=<your connection string> # 4. Generate schema + run migrations (default: Prisma + PostgreSQL) npx @better-auth/cli@latest generate npx prisma migrate dev # 5. Start pnpm dev
Visit http://localhost:3000 — landing page with Sign In / Create Account buttons.
How It Works
Every auth form is split into two files:
| Layer | File | Responsibility |
|---|---|---|
| UI | sign-*-form.tsx | Pure presentational. Receives typed props. Zero auth logic, zero router calls. |
| Logic | sign-*-logic.tsx | "use client". State, zod validation, authClient calls. Passes everything to UI. |
This means you can replace the entire UI without touching auth logic, or reuse the same logic with a completely different design.
Customization
1. Database
Two files work together — keep the same numbered option active in both:
| # | Adapter | Engine | Migration |
|---|---|---|---|
| 1 | Prisma | PostgreSQL (default) | @better-auth/cli generate → prisma migrate dev |
| 2 | Prisma | MySQL / SQLite / CockroachDB | same |
| 3 | Drizzle ORM | pg / MySQL / SQLite | @better-auth/cli generate → drizzle-kit migrate |
| 4 | MongoDB | MongoDB | none (schema-less) |
| 5 | Kysely direct | PostgreSQL | @better-auth/cli migrate |
| 6 | Kysely direct | MySQL | @better-auth/cli migrate |
| 7 | Kysely direct | SQLite | @better-auth/cli migrate |
| 8 | Kysely direct | Bun SQLite | @better-auth/cli migrate |
Each option is fully documented as comments in lib/db.ts and lib/auth.ts. Change one block in each file and run the migration.
build command in package.json is "prisma generate && next build". This is Prisma-specific (options 1–2). If you switch to a different adapter, update the build script accordingly:| Adapter | Build script |
|---|---|
| Prisma (1–2) | "prisma generate && next build" (default) |
| Drizzle (3) | "drizzle-kit generate && next build" |
| MongoDB (4) | "next build" |
| Kysely (5–8) | "next build" |
Prisma and Drizzle need a code-generation step before the build so the ORM client is available at compile time. MongoDB and Kysely don't require code generation.
2. Toggle Providers On/Off
All 10 providers live in components/auth/providers.ts. Each has an enabled boolean:
{
id: "discord",
label: "Discord",
icon: SiDiscord,
brandColor: "#5865F2",
enabled: true, // flip to show in UI
}Then add env vars and register the redirect URI — that's it. Every page using <SignIn> or <SignUp> picks up the change automatically.
| Provider | id | Default |
|---|---|---|
google | ✅ enabled | |
| GitHub | github | ✅ enabled |
| Microsoft | microsoft | ✅ enabled |
| Apple | apple | ✅ enabled |
facebook | ✅ enabled | |
linkedin | ✅ enabled | |
| Discord | discord | disabled |
| Twitter / X | twitter | disabled |
| Twitch | twitch | disabled |
| Spotify | spotify | disabled |
3. Override Providers Per-Page
Both <SignIn> and <SignUp> accept an optional providerOverrides prop that lets you show/hide or rename providers for that specific page only — without changing providers.ts:
// app/sign-in/page.tsx
import { SignIn } from "@/components/auth/sign-in";
export default function SignInPage() {
return (
<main className="flex min-h-svh items-center justify-center p-4">
<SignIn
providerOverrides={{
// Enable Discord just on this page
discord: { enabled: true, label: "Continue with Discord" },
// Hide Facebook on this page
facebook: { enabled: false },
// Rename the Google button
google: { label: "Sign in with Google Workspace" },
}}
/>
</main>
);
}providerOverrides is typed as Partial<Record<SocialProvider, { enabled?: boolean; label?: string }>> — specify only what you want to change.
4. Change Redirect Paths
Both components accept a callbackConfig prop to override where users go after auth:
// Sign-In: redirect to /admin after login
<SignIn
callbackConfig={{
callbackURL: "/admin",
errorCallbackURL: "/sign-in?error=true",
}}
/>
// Sign-Up: different redirect for new OAuth users
<SignUp
callbackConfig={{
callbackURL: "/dashboard",
newUserCallbackURL: "/onboarding", // first-time OAuth users
errorCallbackURL: "/sign-up",
}}
/>| Field | Type | Default | Description |
|---|---|---|---|
callbackURL | string | "/dashboard" | Redirect on successful auth |
errorCallbackURL | string | Current page | Redirect on auth error |
newUserCallbackURL | string | callbackURL | Redirect for first-time OAuth users (sign-up only) |
5. Page Layout and Copy
app/sign-in/page.tsx and app/sign-up/page.tsx are plain RSCs — edit headings, descriptions, and footer links directly there. They are intentionally minimal.
6. Add a Custom Provider Icon
Create any component that satisfies AuthIconComponent (a React.ComponentType<IconProps> — receives size, className, style).
A ready-made template is at components/auth/microsoft-icon.tsx. Copy it, rename it, paste your SVG paths:
// components/auth/my-icon.tsx
import type { IconProps } from "@/types/auth";
export function MyIcon({ size = 16, className, style }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={className}
style={style}
aria-hidden="true"
>
<path d="..." />
</svg>
);
}Register it in providers.ts:
import { MyIcon } from "@/components/auth/my-icon";
{ id: "spotify", label: "Spotify", icon: MyIcon, brandColor: "#1DB954", enabled: true }If the provider ID doesn't exist in the SocialProvider union yet, add it to types/auth.ts and register the server-side config in lib/auth.ts under socialProviders.
7. Swap the Entire UI Shell
The UI components (sign-in-form.tsx, sign-up-form.tsx) are purely presentational — they only receive typed props and fire typed callbacks.
To replace the UI entirely: create a new component that accepts the same prop interface, then update one import line in the logic file.
// components/auth/sign-in/sign-in-logic.tsx
import { SignInFormUI } from "./my-sign-in-form"; // ← change this importYour new UI inherits all state, validation, loading states, and auth calls automatically.
8. Style the Form Wrapper
Both <SignIn> and <SignUp> accept an optional className prop that is forwarded directly to the outermost div of the form. Use it to add borders, shadows, padding, max-width constraints, or any other Tailwind utilities — without touching the form components.
// Wrap the form in a card <SignIn className="rounded-xl border bg-card p-8 shadow-md" /> // Constrain width on a wide layout <SignUp className="mx-auto w-full max-w-sm" /> // Combine with a custom background <SignIn className="rounded-2xl bg-linear-to-b from-muted/40 to-muted/10 p-10" />
The classes are merged with the component's default flex w-full flex-col gap-6, so layout and spacing are preserved unless you explicitly override them.
9. Theme Presets
The design tokens are defined as CSS custom properties in app/globals.css. Two ready-to-use alternative palettes are included at the bottom of that file as commented-out blocks — Ocean Blue and Forest Green. Each palette is a complete light + dark pair.
To switch themes, replace the active :root and .dark blocks at the top of globals.css with the palette of your choice:
/* In app/globals.css — replace the :root block: */
:root {
--primary: oklch(0.488 0.243 264.376);
/* ... rest of Ocean Blue palette */
}
.dark {
--primary: oklch(0.6 0.2 264.376);
/* ... rest of Ocean Blue dark palette */
}| Name | Hue | Best for |
|---|---|---|
| Default (warm) | ~49° | General purpose, neutral warmth (active) |
| Ocean Blue | ~264° | SaaS, developer tools, productivity apps |
| Forest Green | ~155° | Wellness, sustainability, nature products |
All three presets use the same token names, so every component — forms, buttons, inputs, alerts — picks up the new palette automatically. No component changes needed.
:root and .dark. The tokens follow the shadcn/ui convention, so any shadcn palette generator produces a drop-in replacement.10. Dark Mode
Dark mode is active out of the box via next-themes. The ThemeProvider in app/layout.tsx uses attribute="class". All components use Tailwind dark: variants. System theme is used by default.
"use client";
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Toggle
</button>
);
}11. Landing Page
The default landing page lives entirely in components/landing-default/. The root route (app/page.tsx) is a thin wrapper that just imports it — making it trivial to replace.
// app/page.tsx
import { MyLandingPage } from "@/components/my-landing";
export default function HomePage() {
return <MyLandingPage />;
}Version badge (components/landing-default/version-badge.tsx) reads the version dynamically from package.json at build time — no manual updates needed.
12. Demo / Showcase Mode
The project includes a demo mode that lets you deploy it as a public showcase on Vercel (or anywhere) without a database. Visitors can sign in with hardcoded demo credentials and explore the dashboard, but cannot create real accounts.
How Demo Mode Works
| Component | Normal mode | Demo mode |
|---|---|---|
| Sign-in | Better Auth + DB | Validates against DEMO_EMAIL / DEMO_PASSWORD env vars |
| Sign-up | Better Auth + DB | Shows an info notice (registration disabled) |
| Dashboard | Reads session from DB | Reads session from a lightweight cookie |
| Social providers | Shown | Hidden (no DB to store OAuth accounts) |
| Middleware | Calls auth.api.getSession | Checks for the demo_session cookie |
Demo Environment Variables
# Set these three on Vercel (or in .env.local for local testing) NEXT_PUBLIC_DEMO_MODE=true DEMO_EMAIL=demo@example.com DEMO_PASSWORD=Demo1234!
DATABASE_URL and BETTER_AUTH_SECRET can be left empty — they are never used at runtime in demo mode.
Demo Mode Files
| File | Purpose |
|---|---|
lib/demo-session.ts | Cookie helpers + isDemoMode() / getDemoSession() |
app/api/demo-signin/route.ts | Validates demo creds, issues demo_session cookie |
app/api/demo-signout/route.ts | Clears the cookie |
proxy.ts | Bypasses Better Auth in demo mode |
app/dashboard/page.tsx | Reads cookie session instead of hitting DB |
sign-in-logic.tsx | POSTs to /api/demo-signin |
sign-up-logic.tsx | Renders showcase notice instead of form |
sign-out-button.tsx | Calls /api/demo-signout |
Disabling Demo Mode (After Cloning)
To switch to the real Better Auth flow, just remove NEXT_PUBLIC_DEMO_MODE from your environment (or set it to "false"). Then set up your database and auth secrets as described in the Quick Start section above. No code changes needed — every component checks NEXT_PUBLIC_DEMO_MODE at runtime and falls back to the normal Better Auth path automatically.
# 1. Remove (or set to "false") the demo flag NEXT_PUBLIC_DEMO_MODE=false # or just delete the line # 2. Set up your real auth secrets BETTER_AUTH_SECRET=<run: openssl rand -base64 32> DATABASE_URL=postgresql://user:password@host:5432/dbname # 3. Run migrations npx prisma migrate dev # 4. Deploy — everything uses Better Auth automatically
Reading the Session
Client component
"use client";
import { authClient } from "@/lib/auth-client";
export function UserBadge() {
const { data: session } = authClient.useSession();
if (!session) return null;
return <span>{session.user.name}</span>;
}Server component (recommended for protected pages)
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function ProtectedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/sign-in");
return <p>Hello, {"{session.user.name}"}</p>;
}useEffect guards produce. For protecting multiple routes at once, use proxy.ts at the project root (Next.js 16+ replaces middleware.ts with proxy.ts).Type Reference
All types are in types/auth.ts — zero any across the entire codebase.
| Type | Description |
|---|---|
SocialProvider | Union: "google" | "github" | "discord" | ... |
ProviderConfig | { id, label, icon, brandColor, enabled } |
AuthIconComponent | React.ComponentType<IconProps> |
IconProps | { size?, className?, style?, "aria-hidden"? } |
SignUpFormValues | { name, email, password, confirmPassword } |
SignInFormValues | { email, password, rememberMe } |
AuthError | { message: string; code?: string } |
AuthCallbackConfig | { callbackURL, errorCallbackURL, newUserCallbackURL? } |
SignUpFieldErrors | Partial<Record<keyof SignUpFormValues, string>> |
SignInFieldErrors | Partial<Record<keyof SignInFormValues, string>> |
SignUpFormProps | Full prop interface for the sign-up UI |
SignInFormProps | Full prop interface for the sign-in UI |
OAuth Redirect URIs
For each provider you enable, register this callback URL in their developer console:
{NEXT_PUBLIC_APP_URL}/api/auth/callback/{provider-id}Examples:
http://localhost:3000/api/auth/callback/google http://localhost:3000/api/auth/callback/github http://localhost:3000/api/auth/callback/microsoft http://localhost:3000/api/auth/callback/apple http://localhost:3000/api/auth/callback/facebook http://localhost:3000/api/auth/callback/linkedin