
Better Auth Scaffold
- 82 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
better-auth-scaffold is a Claude Code skill for security.
About
better-auth-scaffold is a Claude Code skill for security. It helps solo builders move faster with AI-assisted coding.
- better-auth-scaffold
- Security
- AI-coding skill
Better Auth Scaffold by the numbers
- 82 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,081 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill better-auth-scaffoldAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 82 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with security tasks during AI-assisted development.?
Helps with security tasks during AI-assisted development.
Who is it for?
Best when you're working on security and need structured help with better auth scaffold.
Skip if: Teams with no security needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with security tasks during AI-assisted development., or when better-auth-scaffold is a claude code skill for security.
What you get
Structured output aligned to better-auth-scaffold: better-auth-scaffold, Security.
Files
Better Auth Scaffold (Next.js + Drizzle)
Parameterized templates for bootstrapping a Better Auth setup in a Next.js App Router project using the Drizzle adapter. Each template enforces the conventions documented in `references/conventions.md` — file layout, plugin ordering, env handling, runtime selection.
When to Apply
Reference these templates when:
- Starting a new Next.js project that needs authentication
- Adding Better Auth to an existing Next.js + Drizzle codebase
- Refactoring a partial Better Auth setup that's missing the catch-all route, middleware, or
nextCookies()ordering - Generating a per-feature variant (minimal, social, advanced) on top of an existing project
Setup
Required parameters
| Parameter | Required | Default | Description |
|---|---|---|---|
preset | no | minimal | minimal (email+password) \ |
db_provider | yes | — | pg \ |
app_name | yes | — | Display name (becomes 2FA issuer in authenticator apps when preset=advanced) |
Optional parameters
| Parameter | Default | Description |
|---|---|---|
auth_path | lib/auth.ts | Server auth module path |
client_path | lib/auth-client.ts | Client module path |
api_route_path | app/api/auth/[...all]/route.ts | Catch-all route handler path |
protected_paths | ["/dashboard"] | Routes the middleware guards |
If config.json already exists with values, the skill uses those; otherwise it asks the user.
Available Templates
| Template | Output File | When to emit |
|---|---|---|
| `auth.ts.template` | lib/auth.ts | Always |
| `auth-client.ts.template` | lib/auth-client.ts | Always |
| `route.ts.template` | app/api/auth/[...all]/route.ts | Always |
| `middleware.ts.template` | middleware.ts | When protected_paths is non-empty |
| `env.template` | .env.example | Always |
| `db-schema-better-auth.ts.template` | db/schema/auth.ts | Always (stub — replace with output of better-auth generate) |
| `db-index.ts.template` | db/index.ts | When the project has no Drizzle client yet |
| `email.ts.template` | lib/email.ts | When preset=advanced (and the file doesn't already exist) |
| `sign-in-page.tsx.template` | app/sign-in/page.tsx | When the project has no sign-in page (closes the middleware redirect loop) |
| `permissions.ts.template` | lib/permissions.ts | Optional starter for org/admin plugin work |
How to Use
1. Resolve parameters. Read config.json first; for any missing required parameter (db_provider, app_name), ask the user via AskUserQuestion.
2. Render each template. For each template file:
- Read the template.
- Substitute
{{placeholder}}values ({{app_name}},{{db_provider}}, etc.) with the resolved parameter values. - Apply
PRESET[...]conditional blocks using these rules:
1. Find every pair of marker lines matching // PRESET[<tags>] and // /PRESET[<tags>] (or /* PRESET[...] */ / # PRESET[...] for non-JS files — same syntax, different comment style). 2. Parse <tags> as a comma-separated list (e.g. social,advanced). 3. If the chosen preset value is in <tags> → remove ONLY the two marker lines; keep the lines between them. 4. If the chosen preset value is NOT in <tags> → remove the ENTIRE block including both marker lines. 5. Markers may be nested (e.g. PRESET[advanced] inside PRESET[minimal,social,advanced]); process from inside out.
- For Mustache-style iteration in
middleware.ts.template(// {{#protected_paths}}...// {{/protected_paths}}), repeat the lines between the markers once per item in the list, substituting{{path}}with each value.
3. Write output files. Before writing, check if the target file already exists:
- If it doesn't exist → write it.
- If it exists and is identical → no-op.
- If it exists and differs → show a diff and ask the user (overwrite / merge / skip).
4. Run the CLI sequence. After all files are written:
npx @better-auth/cli@latest generate
npx drizzle-kit generate
npx drizzle-kit migrateThese commands replace the placeholder db/schema/auth.ts with the real schema and apply migrations.
5. Print next steps. Tell the user to:
- Fill in
.env.local(copy from.env.example) - Generate a secret:
openssl rand -base64 32 - Register OAuth redirect URIs with each provider console (for
social/advancedpresets) - Implement
lib/email.tsto wire transactional email (foradvancedpreset)
Conventions
Read `references/conventions.md` for the rationale behind every convention these templates encode. Highlights:
lib/auth.tsis server-only (import "server-only")nextCookies()is ALWAYS the last plugin in the array- Middleware does a cookie-presence check only; real validation in pages
- All secrets via
process.env.*, never inline - Sliding-window sessions with
cookieCacheenabled
Related Skills
- `better-auth` — Library/API Reference with 42 rules covering setup, sessions, security, plugins, and migration. The templates here are one canonical realization of those rules; read the rule for the underlying reasoning when you need to deviate.
Gotchas
See `gotchas.md` — initialized empty, populated as we discover them.
// =============================================================================
// Template: lib/auth-client.ts
// Parameters:
// - preset: "minimal" | "social" | "advanced" (required)
//
// PRESET[minimal,social,advanced] → always
// PRESET[advanced] → twoFactor + magicLink client plugins
// =============================================================================
import { createAuthClient } from "better-auth/react";
import { inferAdditionalFields } from "better-auth/client/plugins";
// PRESET[advanced]
import { twoFactorClient, magicLinkClient } from "better-auth/client/plugins";
// /PRESET[advanced]
import type { auth } from "./auth";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL,
plugins: [
inferAdditionalFields<typeof auth>(),
// PRESET[advanced]
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = "/two-factor";
},
}),
magicLinkClient(),
// /PRESET[advanced]
],
});
// Convenience re-exports — pick whichever style matches your codebase
export const {
signIn,
signUp,
signOut,
useSession,
getSession,
} = authClient;
// =============================================================================
// Template: lib/auth.ts
// Parameters:
// - preset: "minimal" | "social" | "advanced" (required)
// - db_provider: "pg" | "mysql" | "sqlite" (required)
// - app_name: string (required, becomes 2FA issuer when preset=advanced)
//
// Conditional sections are marked with PRESET tags below. When rendering:
// - PRESET[minimal,social,advanced] → always include
// - PRESET[social,advanced] → include for social & advanced
// - PRESET[advanced] → include only for advanced
// - Remove all PRESET markers from the final file.
// =============================================================================
import "server-only";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
// PRESET[advanced]
import { twoFactor, magicLink } from "better-auth/plugins";
// /PRESET[advanced]
import { db } from "@/db";
import { env } from "@/env";
// PRESET[advanced]
import { sendEmail } from "@/lib/email";
// /PRESET[advanced]
export const auth = betterAuth({
appName: "{{app_name}}",
baseURL: env.BETTER_AUTH_URL,
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "{{db_provider}}",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
minPasswordLength: 10,
autoSignIn: false,
revokeSessionsOnPasswordReset: true,
sendResetPassword: async ({ user, url }) => {
// PRESET[advanced]
await sendEmail({
to: user.email,
subject: "Reset your {{app_name}} password",
text: `Reset: ${url}`,
});
// /PRESET[advanced]
// PRESET[minimal,social]
// TODO: wire to your transactional email provider (Resend, Postmark, SendGrid, ...).
// For the `advanced` preset this is auto-wired to @/lib/email.
console.log(`[auth] reset password link for ${user.email}: ${url}`);
// /PRESET[minimal,social]
},
},
// PRESET[advanced]
emailVerification: {
sendOnSignIn: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your {{app_name}} email",
text: `Verify: ${url}`,
});
},
},
// /PRESET[advanced]
// PRESET[social,advanced]
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
github: {
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
},
},
// /PRESET[social,advanced]
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // slide once per day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [
env.BETTER_AUTH_URL,
...(process.env.NODE_ENV !== "production"
? ["http://localhost:3000"]
: []),
],
rateLimit: {
enabled: true,
window: 60,
max: 100,
customRules: {
"/sign-in/email": { window: 60, max: 5 },
"/forget-password": { window: 300, max: 3 },
},
},
plugins: [
// PRESET[advanced]
twoFactor({
otpOptions: { period: 30, digits: 6 },
backupCodes: { length: 10, amount: 10 },
}),
magicLink({
expiresIn: 60 * 5, // 5 minutes
sendMagicLink: async ({ email, url }) => {
await sendEmail({
to: email,
subject: "Sign in to {{app_name}}",
text: `Sign in: ${url}\nThis link expires in 5 minutes.`,
});
},
}),
// /PRESET[advanced]
nextCookies(), // MUST be last — see references/conventions.md § Plugin Ordering
],
});
export type Session = typeof auth.$Infer.Session;
// =============================================================================
// Template: db/index.ts
// Parameters:
// - db_provider: "pg" | "mysql" | "sqlite" (required)
//
// Drizzle client singleton. `lib/auth.ts` imports `db` from here.
//
// Emitted only when the project doesn't already have a Drizzle client at
// @/db. If you have an existing client elsewhere (e.g. `db/client.ts`),
// adjust `auth_path`'s import or skip this template.
// =============================================================================
// PRESET[minimal,social,advanced]
// --- Postgres (db_provider = "pg") -------------------------------------------
// import { drizzle } from "drizzle-orm/node-postgres";
// import { Pool } from "pg";
// import * as schema from "./schema/auth";
//
// const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// export const db = drizzle(pool, { schema });
// --- MySQL (db_provider = "mysql") -------------------------------------------
// import { drizzle } from "drizzle-orm/mysql2";
// import { createPool } from "mysql2/promise";
// import * as schema from "./schema/auth";
//
// const pool = createPool({ uri: process.env.DATABASE_URL });
// export const db = drizzle(pool, { schema, mode: "default" });
// --- SQLite (db_provider = "sqlite") -----------------------------------------
// import { drizzle } from "drizzle-orm/better-sqlite3";
// import Database from "better-sqlite3";
// import * as schema from "./schema/auth";
//
// const sqlite = new Database(process.env.DATABASE_URL ?? "local.db");
// export const db = drizzle(sqlite, { schema });
// /PRESET[minimal,social,advanced]
// Uncomment the block matching your db_provider. Install the corresponding
// peer dependencies:
// pg → pg, drizzle-orm
// mysql → mysql2, drizzle-orm
// sqlite → better-sqlite3, drizzle-orm
export const db = {} as never; // placeholder so imports compile; replace per above
// =============================================================================
// Template: db/schema/auth.ts (Drizzle)
// Parameters:
// - db_provider: "pg" | "mysql" | "sqlite" (required)
// - preset: "minimal" | "social" | "advanced" (required)
//
// This is a HAND-STUBBED starter schema covering the four Better Auth core
// tables (user, session, account, verification) plus advanced-preset additions
// (twoFactor). Use it so the project compiles before the CLI generator runs.
//
// AFTER scaffolding, replace this file with the canonical generated schema:
//
// npx @better-auth/cli@latest generate # produces ./auth-schema.ts
// # merge into db/schema/auth.ts, then:
// npx drizzle-kit generate # SQL migration
// npx drizzle-kit migrate # apply it
//
// The generator produces the same shape with correct type widths and any extra
// columns from plugins beyond what's stubbed here.
//
// See references/conventions.md § Schema Generation.
// =============================================================================
// PRESET[minimal,social,advanced]
// --- Postgres (db_provider = "pg") -------------------------------------------
// Uncomment if db_provider=pg
//
// import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
//
// export const user = pgTable("user", {
// id: text("id").primaryKey(),
// email: text("email").notNull().unique(),
// emailVerified: boolean("emailVerified").notNull().default(false),
// name: text("name"),
// image: text("image"),
// // PRESET[advanced]
// twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false),
// // /PRESET[advanced]
// createdAt: timestamp("createdAt").notNull().defaultNow(),
// updatedAt: timestamp("updatedAt").notNull().defaultNow(),
// });
//
// export const session = pgTable("session", {
// id: text("id").primaryKey(),
// userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
// token: text("token").notNull().unique(),
// expiresAt: timestamp("expiresAt").notNull(),
// ipAddress: text("ipAddress"),
// userAgent: text("userAgent"),
// createdAt: timestamp("createdAt").notNull().defaultNow(),
// updatedAt: timestamp("updatedAt").notNull().defaultNow(),
// });
//
// export const account = pgTable("account", {
// id: text("id").primaryKey(),
// userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
// providerId: text("providerId").notNull(),
// accountId: text("accountId").notNull(),
// password: text("password"),
// accessToken: text("accessToken"),
// refreshToken: text("refreshToken"),
// accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
// refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
// scope: text("scope"),
// idToken: text("idToken"),
// createdAt: timestamp("createdAt").notNull().defaultNow(),
// updatedAt: timestamp("updatedAt").notNull().defaultNow(),
// });
//
// export const verification = pgTable("verification", {
// id: text("id").primaryKey(),
// identifier: text("identifier").notNull(),
// value: text("value").notNull(),
// expiresAt: timestamp("expiresAt").notNull(),
// createdAt: timestamp("createdAt").notNull().defaultNow(),
// updatedAt: timestamp("updatedAt").notNull().defaultNow(),
// });
//
// PRESET[advanced]
// export const twoFactor = pgTable("twoFactor", {
// id: text("id").primaryKey(),
// userId: text("userId").notNull().references(() => user.id, { onDelete: "cascade" }),
// secret: text("secret").notNull(),
// backupCodes: text("backupCodes").notNull(), // JSON-encoded array
// });
// /PRESET[advanced]
// /PRESET[minimal,social,advanced]
// --- MySQL (db_provider = "mysql") -------------------------------------------
// Use `mysqlTable`, `varchar(255)`/`text`, `datetime`/`timestamp`. Same shape.
// Generator handles the type widths correctly.
// --- SQLite (db_provider = "sqlite") -----------------------------------------
// Use `sqliteTable`, `text("id").primaryKey()`, `integer("createdAt", { mode: "timestamp" })`.
// Booleans stored as integers (0/1).
// =============================================================================
// IMPORTANT: this file is a STUB to keep the project compiling. After running
// `npx @better-auth/cli generate`, replace its contents with the generated
// schema and re-export from `db/schema/index.ts` so the rest of your app
// (and Drizzle Studio) see the same tables.
// =============================================================================
export {};
// =============================================================================
// Template: lib/email.ts
// Parameters: (none)
//
// Single transactional email sender. The advanced preset's auth.ts imports
// `sendEmail` from here for verification, password reset, and magic-link
// delivery. Implement once, reuse everywhere.
//
// Emitted only when preset=advanced.
//
// The example below uses Resend (single API call, cheapest setup). Swap to
// Postmark, SendGrid, AWS SES, or your provider — the function signature
// stays the same, so auth.ts doesn't change.
// =============================================================================
import "server-only";
interface SendEmailArgs {
to: string;
subject: string;
text: string;
html?: string;
}
export async function sendEmail({ to, subject, text, html }: SendEmailArgs): Promise<void> {
const apiKey = process.env.RESEND_API_KEY;
const from = process.env.EMAIL_FROM;
if (!apiKey || !from) {
// Fail loud in dev; production should have these set.
if (process.env.NODE_ENV === "production") {
throw new Error("Email is not configured (missing RESEND_API_KEY or EMAIL_FROM)");
}
console.warn(`[email] Dev mode — would send to ${to}:`);
console.warn(` Subject: ${subject}`);
console.warn(` Body: ${text}`);
return;
}
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from,
to,
subject,
text,
html: html ?? text,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Email delivery failed (${response.status}): ${body}`);
}
}
# =============================================================================
# Template: .env.example
# Parameters:
# - preset: "minimal" | "social" | "advanced" (required)
# - db_provider: "pg" | "mysql" | "sqlite" (required)
#
# Generate the secret with:
# openssl rand -base64 32
#
# IMPORTANT: copy this to .env.local and fill in real values. .env.local is
# gitignored; never commit secrets.
# =============================================================================
# --- Core --------------------------------------------------------------------
BETTER_AUTH_SECRET= # 32+ bytes; openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
# --- Database ----------------------------------------------------------------
# PRESET[minimal,social,advanced]
# For db_provider=pg:
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
# For db_provider=mysql:
# DATABASE_URL=mysql://user:pass@localhost:3306/myapp
# For db_provider=sqlite:
# DATABASE_URL=file:./local.db
# /PRESET[minimal,social,advanced]
# PRESET[social,advanced]
# --- OAuth Providers ---------------------------------------------------------
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# /PRESET[social,advanced]
# PRESET[advanced]
# --- Email Provider (for verification, password reset, magic link) -----------
# Pick one — these are stubs; the auth.ts file imports from @/lib/email which
# you implement once and reuse for all three flows.
RESEND_API_KEY=
# or POSTMARK_SERVER_TOKEN=
# or SENDGRID_API_KEY=
EMAIL_FROM=noreply@example.com
# /PRESET[advanced]
// =============================================================================
// Template: middleware.ts
// Parameters:
// - protected_paths: string[] (required, e.g. ["/dashboard", "/settings"])
//
// This middleware does a cheap Edge-safe cookie-presence check and redirects
// unauthenticated users to /sign-in. It deliberately does NOT call auth.api —
// that would require the Node.js runtime and a DB lookup on every request.
//
// Full session validation happens in the page/route handler via
// auth.api.getSession({ headers }). See references/conventions.md § Middleware.
// =============================================================================
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("redirect", request.nextUrl.pathname);
return NextResponse.redirect(signInUrl);
}
return NextResponse.next();
}
// IMPORTANT: emit BOTH `"{{path}}"` and `"{{path}}/:path*"` per protected route.
// `/:path*` does not match the bare prefix in Next.js — visiting `/dashboard`
// directly would bypass the auth check if only the wildcard form is listed.
export const config = {
matcher: [
// {{#protected_paths}}
"{{path}}",
"{{path}}/:path*",
// {{/protected_paths}}
],
};
// =============================================================================
// Template: lib/permissions.ts
// Parameters: (none)
//
// Emitted only when preset includes organization/admin plugins.
// In the current scaffold (minimal/social/advanced presets), this file is
// OPTIONAL — included as a starting point if you later add organization+admin.
//
// Imported by BOTH lib/auth.ts (server) AND lib/auth-client.ts (client) so the
// role and permission definitions are a single source of truth. Drift between
// server and client role tables = enforcement bypass.
//
// See references/conventions.md § Shared Access Control.
// =============================================================================
import { createAccessControl } from "better-auth/plugins/access";
export const statements = {
invoices: ["read", "write", "delete"] as const,
members: ["read", "invite", "remove"] as const,
// Add resources as your domain grows. Each verb here becomes a permission
// string roles can grant ("invoices:write", "members:invite", ...).
} as const;
export const ac = createAccessControl(statements);
export const owner = ac.newRole({
invoices: ["read", "write", "delete"],
members: ["read", "invite", "remove"],
});
export const admin = ac.newRole({
invoices: ["read", "write"],
members: ["read", "invite"],
});
export const member = ac.newRole({
invoices: ["read"],
});
// Wire these into server and client like:
//
// // lib/auth.ts
// import { organization } from "better-auth/plugins";
// import { ac, owner, admin, member } from "./permissions";
// plugins: [organization({ ac, roles: { owner, admin, member } }), ...]
//
// // lib/auth-client.ts
// import { organizationClient } from "better-auth/client/plugins";
// import { ac, owner, admin, member } from "./permissions";
// plugins: [organizationClient({ ac, roles: { owner, admin, member } }), ...]
// =============================================================================
// Template: app/api/auth/[...all]/route.ts
// Parameters: (none)
//
// This is the catch-all route handler. The folder name [...all] is REQUIRED —
// Better Auth needs to match every sub-path under /api/auth (sign-in, sign-up,
// callback/{provider}, verify-email, ...). Renaming the segment will produce
// 404 errors on every auth request.
//
// See references/conventions.md § Route Mounting.
// =============================================================================
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
// =============================================================================
// Template: app/sign-in/page.tsx
// Parameters:
// - preset: "minimal" | "social" | "advanced" (required)
//
// Minimal sign-in page wired to authClient. Closes the middleware's redirect
// loop — unauthenticated users hitting /dashboard land here.
//
// This is a STARTING POINT. Replace the inline form with your design system,
// add proper error rendering, loading states, and OAuth provider buttons.
// =============================================================================
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
export default function SignInPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleEmailSignIn(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
const { error: err } = await authClient.signIn.email({
email,
password,
callbackURL: "/dashboard",
});
setLoading(false);
if (err) setError(err.message ?? "Sign-in failed");
}
// PRESET[advanced]
async function handleMagicLink() {
setError(null);
setLoading(true);
const { error: err } = await authClient.signIn.magicLink({
email,
callbackURL: "/dashboard",
});
setLoading(false);
if (err) setError(err.message ?? "Magic link send failed");
else setError("Check your email for a sign-in link.");
}
// /PRESET[advanced]
return (
<main style={{ maxWidth: 360, margin: "5rem auto" }}>
<h1>Sign in</h1>
<form onSubmit={handleEmailSignIn}>
<label>
Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</label>
<label>
Password
<input
type="password"
required
minLength={10}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in with email"}
</button>
</form>
{/* PRESET[advanced] */}
<button type="button" onClick={handleMagicLink} disabled={loading || !email}>
Email me a sign-in link
</button>
{/* /PRESET[advanced] */}
{/* PRESET[social,advanced] */}
<div style={{ marginTop: "1rem" }}>
<button
type="button"
onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" })}
>
Continue with Google
</button>
<button
type="button"
onClick={() => authClient.signIn.social({ provider: "github", callbackURL: "/dashboard" })}
>
Continue with GitHub
</button>
</div>
{/* /PRESET[social,advanced] */}
{error && <p role="alert">{error}</p>}
</main>
);
}
{
"preset": "minimal",
"db_provider": "",
"app_name": "",
"auth_path": "lib/auth.ts",
"client_path": "lib/auth-client.ts",
"api_route_path": "app/api/auth/[...all]/route.ts",
"protected_paths": ["/dashboard"],
"_setup_instructions": {
"preset": "Which plugin set to scaffold: 'minimal' (email+password only), 'social' (adds Google + GitHub), or 'advanced' (social + twoFactor + magicLink). Defaults to 'minimal' — easiest to extend later.",
"db_provider": "Drizzle database provider: 'pg' (PostgreSQL), 'mysql', or 'sqlite'. Required.",
"app_name": "Display name used as the 2FA issuer in authenticator apps (Google Authenticator, 1Password, etc.) and referenced in email templates. Required.",
"auth_path": "Where to write the server auth module. Default 'lib/auth.ts' works for the standard Next.js layout.",
"client_path": "Where to write the client module. Default 'lib/auth-client.ts'.",
"api_route_path": "Path to the catch-all route handler. MUST include '[...all]' or another catch-all segment.",
"protected_paths": "List of route prefixes the middleware redirects unauthenticated users away from. Default ['/dashboard']."
}
}
Gotchas
Specific failure modes discovered while using this scaffold. Append new entries with the date — never delete; old gotchas are still informative even after they're fixed upstream.
---
No known gotchas yet.
{
"version": "1.0.3",
"organization": "Personal",
"technology": "Better Auth + Next.js App Router + Drizzle",
"discipline": "extraction",
"type": "scaffolding",
"date": "May 2026",
"abstract": "Convention-enforced templates for scaffolding a Better Auth setup in a Next.js App Router project using the Drizzle adapter. Produces 7 template files (server auth, client auth, catch-all route, Edge middleware, env example, Drizzle schema placeholder, optional permissions module) parameterized by three plugin presets (minimal email+password, social with Google+GitHub, advanced with twoFactor+magicLink) and three Drizzle providers (pg, mysql, sqlite). Templates enforce server-only auth modules, plugin ordering (nextCookies last), Edge-safe middleware with per-page session validation, and env-loaded secrets — the same conventions documented as rules in the companion better-auth skill.",
"references": [
"https://www.better-auth.com/docs/installation",
"https://www.better-auth.com/docs/integrations/next",
"https://www.better-auth.com/docs/adapters/drizzle",
"https://www.better-auth.com/docs/concepts/cli",
"https://www.better-auth.com/docs/concepts/session-management",
"https://www.better-auth.com/docs/plugins/2fa",
"https://www.better-auth.com/docs/plugins/magic-link",
"https://github.com/better-auth/better-auth"
]
}
Conventions
The templates in this skill enforce a small set of layout and configuration conventions. This document explains the WHY for each — so when you have to deviate (different framework, monorepo layout, exotic adapter), you can make an informed exception rather than copy-paste-and-hope.
---
File Layout
lib/auth.ts is server-only
Every template imports the auth instance from @/lib/auth. The file's first line is import "server-only" so any accidental client import becomes a build error.
Why: BETTER_AUTH_SECRET, OAuth client secrets, and database credentials all live on the auth instance. If a client component imports it (even just for a type), the bundler may pull the entire module into the browser bundle. server-only is the cheap insurance against that.
lib/auth-client.ts is the only client-facing module
createAuthClient is the public surface. Use it everywhere on the client; never reach into better-auth/react directly from a component.
Why: When you later need to add inferAdditionalFields, twoFactorClient, or any other client plugin, there's one place to wire it. Components don't need to know about plugins.
Database client at @/db, not duplicated
The auth.ts template imports db from @/db. We do NOT instantiate a second Pool or PrismaClient for Better Auth.
Why: Serverless cold starts open new database connections. Two clients = double the open connections per instance. At concurrency limits this manifests as random too many connections errors that look like auth bugs.
---
Plugin Ordering
nextCookies() is ALWAYS the last entry in plugins: [...]
Server Actions can't return a Set-Cookie header — they have to write through next/headers' cookie store. nextCookies() intercepts cookie writes from every preceding plugin and routes them to the Next API.
Why: If nextCookies runs before another plugin in the array, that later plugin's cookie writes are missed. Symptoms: signIn.email returns success, but the browser never receives the session cookie, and the user appears unauthenticated on the next request.
Plugins pair: every server plugin gets its client counterpart
The templates enforce this for the advanced preset: twoFactor ↔ twoFactorClient, magicLink ↔ magicLinkClient.
Why: Server plugins add endpoints; client plugins add the typed method bindings (authClient.twoFactor.enable, authClient.signIn.magicLink). Forgetting the client side leaves you calling authClient.signIn.magicLink({ email }) and getting a TypeError at runtime.
---
Route Mounting
/api/auth/[...all] — catch-all is mandatory
The folder MUST be named [...all] (or any other catch-all name; what matters is the bracket-dot-dot-dot syntax). The handler MUST be mounted via toNextJsHandler(auth) and exported as both GET and POST.
Why: Better Auth exposes dozens of sub-paths (/api/auth/sign-in/social, /api/auth/callback/google, /api/auth/verify-email, ...). Without the catch-all, every one of them 404s.
App Router only
These templates target the App Router (app/api/auth/[...all]/route.ts). For Pages Router, use pages/api/auth/[...all].ts with toNodeHandler(auth) and export const config = { api: { bodyParser: false } }.
Why: App Router's route.ts doesn't pre-parse bodies; Pages Router does. Pre-parsing breaks the handler because Better Auth needs the raw stream.
---
Middleware
middleware.ts stays on Edge; full session check happens in pages
The middleware template only calls getSessionCookie(request) — a synchronous, cookie-presence check that runs on the Edge runtime. The actual session validation (which queries the database) happens in the page or route handler via auth.api.getSession({ headers }).
Why: Edge runtime can't run most DB drivers (pg, Prisma's binary engine, etc.). Calling auth.api.getSession from default-Edge middleware crashes at build or first request. Two valid alternatives:
- Opt the middleware into
runtime: "nodejs"(Next 15.2+) — adds DB latency to every protected route. - Use the cookie check + per-page validation pattern this template enforces — faster, but a stale cookie evades the check until the page validates.
The cookie check is enough for routing; the per-page check is the real enforcement.
---
Configuration
Env vars loaded via process.env.* (or a zod-validated env module)
Templates import OAuth secrets, BETTER_AUTH_SECRET, and BETTER_AUTH_URL from env, never inline.
Why: Inlining a secret in source means: (1) it lands in git history forever, (2) any console.log(auth.options) dumps it, (3) typo'd auth.ts imported from a client component leaks it to the browser bundle.
BETTER_AUTH_SECRET ≥ 32 bytes, unique per environment
Generate with openssl rand -base64 32.
Why: The secret signs session cookies. Reusing dev's secret in prod is identical to having no secret. Rotating it without coordination invalidates every active session simultaneously.
Per-environment baseURL set explicitly, not inferred
The template always sets baseURL from env. Defaults that infer from the request Host header are wrong behind proxies and on preview deploys.
Why: Inferred baseURL produces broken OAuth callbacks (redirect_uri_mismatch), password-reset emails that point at localhost, and verification links that 404.
---
Sessions
Sliding-window: expiresIn: 7d, updateAge: 1d
Templates set both. Active users stay logged in; inactive sessions expire on schedule; the database absorbs one update per user per day, not per request.
Why: Setting only expiresIn gives a hard fixed window — active users get booted mid-session. Setting updateAge: 0 writes to the DB on every request — write amplification.
cookieCache.maxAge: 5 * 60 (5 minutes)
Templates enable cookieCache. Trade-off: ~100× reduction in session-table reads at the cost of up to 5 minutes' delay propagating session revocation across other tabs/devices.
Why: Every server component on a page calls getSession(). Without the cache, that's a SELECT on session per component per render. With the cache, the cookie is locally validated by signature for 5 minutes.
---
Schema Generation
Run better-auth generate after every config change, then run your ORM's migrate
db/schema/auth.ts is a PLACEHOLDER. The real schema comes from the CLI:
npx @better-auth/cli@latest generate # writes the schema fragment
npx drizzle-kit generate # produces SQL migration
npx drizzle-kit migrate # applies itWhy: Plugins (twoFactor, organization, admin, passkey) each add tables and columns. If you add a plugin and don't regenerate, the plugin's endpoints fail at runtime with "table does not exist". Wire generate into your predeploy script.
---
Shared Access Control
lib/permissions.ts is imported by BOTH server and client
For multi-tenant scaffolds (organization + admin plugins), the permissions.ts template is the single source of truth for the access controller (ac) and the named roles. Both lib/auth.ts and lib/auth-client.ts import from it.
Why: Defining roles twice — once on the server, once on the client — guarantees drift. The server enforces stricter permissions than the client thinks exist, so the UI shows actions the server then rejects. Single import = no drift.
Related skills
FAQ
What does better-auth-scaffold do?
better-auth-scaffold is a Claude Code skill for security.
When should I use better-auth-scaffold?
When you need to helps with security tasks during AI-assisted development., or when better-auth-scaffold is a claude code skill for security.
What are the main capabilities?
better-auth-scaffold; Security; AI-coding skill.