[Full-stack]
SaaS Boilerplate
A production-ready Next.js 16 SaaS starter built to a detailed blueprint: Better Auth with email/password, OAuth, 2FA, and passkeys; Drizzle ORM on PostgreSQL; admin panel with impersonation and bans; Zod form validation; React Email transactional templates; Magic UI animated landing page; and a full developer toolchain with Bun, Vitest, Lefthook, Knip, and Commitlint.
This project follows a detailed blueprint (PROJECT.md) whose goal is to
replace Clerk with Better Auth while keeping every user journey a serious
product needs: email/password, social login, email verification, password
reset, 2FA, passkeys, an admin panel with impersonation, and a marketing
landing page with animated Magic UI components. Multi-tenancy is explicitly
out of scope for v1 — the schema and auth layers are structured to add it
later without a rewrite.
Project Structure
.
├── app/
│ ├── (admin)/admin/ # Admin dashboard, stats, user table
│ ├── (auth)/ # sign-in, sign-up, forgot/reset-password,
│ │ # verify-email, verification-sent
│ ├── (dashboard)/ # Authenticated dashboard + account settings
│ ├── api/
│ │ ├── auth/[...all]/ # Better Auth catch-all route handler
│ │ └── health/ # DB health-check endpoint → 200 / 503
│ └── page.tsx # Marketing landing page (Magic UI)
├── db/
│ └── schema.ts # Drizzle: user, session, account,
│ # verification, passkey, two_factor
├── emails/ # React Email: verify-email, reset-password
├── lib/
│ ├── auth.ts # betterAuth() server instance
│ ├── auth-client.ts # createAuthClient() with admin/2FA/passkey
│ ├── auth-session.ts # getSession, requireSession, requireAdmin
│ ├── Env.ts # @t3-oss/env-nextjs typed env validation
│ ├── drizzle.ts # pg Pool + drizzle() singleton
│ ├── db-health.ts # Postgres check with recovery suggestions
│ ├── logger.ts # LogTape console sink
│ ├── dal/ # user-dal.ts, session-dal.ts (admin ops)
│ └── schemas/auth.ts # Zod schemas for all auth forms
├── server/dal/auth.ts # server-only: getSession, requireSession
├── components/
│ ├── theme-provider.tsx # next-themes + D-key toggle shortcut
│ └── ui/ # Shadcn + Magic UI components
├── migrations/ # Committed Drizzle migrations (0000, 0001)
├── proxy.ts # Next.js 16 proxy (cookie-based redirects)
├── scripts/check-db.ts # CLI health check → exit 0/1
├── lefthook.yml # pre-push: vitest + typecheck + knip
└── vitest.config.ts
Better Auth Configuration
lib/auth.ts is the server instance. The Drizzle adapter receives the full
schema export including plugin tables. Four plugins are registered — admin(),
twoFactor(), passkey(), and nextCookies() last. nextCookies() must be
the final plugin so it can intercept Set-Cookie headers from server actions
before Next.js seals the response:
// lib/auth.ts
export const const auth: anyauth = betterAuth({
appName: stringappName: "SaaS",
baseURL: anybaseURL: env.BETTER_AUTH_URL,
basePath: stringbasePath: "/api/auth",
secret: anysecret: env.BETTER_AUTH_SECRET,
database: anydatabase: drizzleAdapter(db, { provider: stringprovider: "pg", schema: anyschema }),
emailAndPassword: {
enabled: boolean;
minPasswordLength: number;
maxPasswordLength: number;
requireEmailVerification: boolean;
sendResetPassword: (data: any) => Promise<any>;
}
emailAndPassword: {
enabled: booleanenabled: true,
minPasswordLength: numberminPasswordLength: 8,
maxPasswordLength: numbermaxPasswordLength: 128,
requireEmailVerification: booleanrequireEmailVerification: true,
sendResetPassword: (data: any) => Promise<any>sendResetPassword: async (data: anydata) => sendAuthEmail({
action: stringaction: "reset-password",
email: anyemail: data: anydata.user.email,
url: anyurl: data: anydata.url,
userName: anyuserName: data: anydata.user.name,
}),
},
emailVerification: {
sendOnSignUp: boolean;
autoSignInAfterVerification: boolean;
sendVerificationEmail: (data: any) => Promise<any>;
}
emailVerification: {
sendOnSignUp: booleansendOnSignUp: true,
autoSignInAfterVerification: booleanautoSignInAfterVerification: true,
sendVerificationEmail: (data: any) => Promise<any>sendVerificationEmail: async (data: anydata) => sendAuthEmail({ action: stringaction: "verify-email", ... }),
},
socialProviders: {
google?: {
clientId: any;
clientSecret: any;
} | undefined;
github?: {
clientId: any;
clientSecret: any;
} | undefined;
}
socialProviders: {
// Only wired if env vars are present — no errors in local dev without OAuth
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
? { github: {
clientId: any;
clientSecret: any;
}
github: { clientId: anyclientId: env.GITHUB_CLIENT_ID, clientSecret: anyclientSecret: env.GITHUB_CLIENT_SECRET } }
: {}),
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? { google: {
clientId: any;
clientSecret: any;
}
google: { clientId: anyclientId: env.GOOGLE_CLIENT_ID, clientSecret: anyclientSecret: env.GOOGLE_CLIENT_SECRET } }
: {}),
},
session: {
expiresIn: number;
updateAge: number;
cookieCache: {
enabled: boolean;
maxAge: number;
};
}
session: {
expiresIn: numberexpiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: numberupdateAge: 60 * 60 * 24, // refresh daily
cookieCache: {
enabled: boolean;
maxAge: number;
}
cookieCache: { enabled: booleanenabled: true, maxAge: numbermaxAge: 60 * 5 }, // 5-minute cookie cache
},
trustedOrigins: any[]trustedOrigins: [env.BETTER_AUTH_URL, env.NEXT_PUBLIC_APP_URL].Array<any>.filter(predicate: (value: any, index: number, array: any[]) => unknown, thisArg?: any): any[] (+1 overload)Returns the elements of an array that meet the condition specified in a callback function.filter(var Boolean: BooleanConstructorBoolean),
advanced: {
useSecureCookies: boolean;
}
advanced: { useSecureCookies: booleanuseSecureCookies: env.NODE_ENV === "production" },
plugins: any[]plugins: [admin(), twoFactor(), passkey(), nextCookies()], // nextCookies LAST
})
export type type Session = anySession = typeof const auth: anyauth.$Infer.Session
export type type AuthUser = anyAuthUser = type Session = anySession["user"]
The route handler exports all HTTP methods from toNextJsHandler:
// app/api/auth/[...all]/route.ts
export const const runtime: "nodejs"runtime = 'nodejs';
export const { const GET: anyGET, const POST: anyPOST, const PATCH: anyPATCH, const PUT: anyPUT, const DELETE: anyDELETE } = toNextJsHandler(auth);
The browser client is created in lib/auth-client.ts with the baseURL
explicitly set so it works in both SSR and browser contexts — NEXT_PUBLIC_APP_URL
is used when present, otherwise it falls back to a relative path:
// lib/auth-client.ts
const const baseURL: stringbaseURL = var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedNEXT_PUBLIC_APP_URL
? `${var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.stringNEXT_PUBLIC_APP_URL}/api/auth`
: '/api/auth';
export const const authClient: anyauthClient = createAuthClient({
baseURL: stringbaseURL,
plugins: any[]plugins: [adminClient(), twoFactorClient(), passkeyClient()],
});
Database Schema
db/schema.ts defines all tables in one file, with plugin columns added
directly to the base tables rather than in separate files. The user table
carries admin and 2FA fields; passkey and two_factor are standalone
plugin tables. All relations are declared for future join support:
// db/schema.ts
export const const user: anyuser = pgTable('user', {
id: anyid: text('id').primaryKey(),
name: anyname: text('name').notNull(),
email: anyemail: text('email').notNull().unique(),
emailVerified: anyemailVerified: boolean('email_verified').default(false).notNull(),
image: anyimage: text('image'),
createdAt: anycreatedAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: anyupdatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new var Date: DateConstructor
new () => Date (+4 overloads)
Date())
.notNull(),
// Admin plugin
role: anyrole: text('role').default('user').notNull(),
banned: anybanned: boolean('banned').default(false).notNull(),
banReason: anybanReason: text('ban_reason'),
banExpires: anybanExpires: timestamp('ban_expires'),
// 2FA plugin
twoFactorEnabled: anytwoFactorEnabled: boolean('two_factor_enabled').default(false).notNull(),
});
export const const session: anysession = pgTable(
'session',
{
// ...standard fields...
impersonatedBy: anyimpersonatedBy: text('impersonated_by'), // tracks admin impersonation
},
(table: anytable) => [index('session_userId_idx').on(table: anytable.userId)],
);
export const const passkey: anypasskey = pgTable(
'passkey',
{
id: anyid: text('id').primaryKey(),
name: anyname: text('name'),
publicKey: anypublicKey: text('public_key').notNull(),
userId: anyuserId: text('user_id')
.notNull()
.references(() => const user: anyuser.id, { onDelete: stringonDelete: 'cascade' }),
credentialId: anycredentialId: text('credential_id').notNull(),
counter: anycounter: integer('counter').notNull(),
deviceType: anydeviceType: text('device_type').notNull(),
backedUp: anybackedUp: boolean('backed_up').notNull(),
transports: anytransports: text('transports'),
aaguid: anyaaguid: text('aaguid'),
},
(table: anytable) => [
index('passkey_userId_idx').on(table: anytable.userId),
index('passkey_credentialId_idx').on(table: anytable.credentialId),
],
);
Two migrations are committed. The first (0000_init_better_auth.sql) creates
the base Better Auth tables — user, session, account, verification —
with cascading foreign keys and B-tree indexes. The second
(0001_glossy_quasar.sql) adds all plugin columns in one additive step:
-- migrations/0001_glossy_quasar.sql
CREATE TABLE "passkey" (...);
CREATE TABLE "two_factor" (...);
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false NOT NULL;
ALTER TABLE "user" ADD COLUMN "ban_reason" text;
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;
ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false NOT NULL;
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");
CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");
CREATE INDEX "twoFactor_secret_idx" ON "two_factor" USING btree ("secret");
CREATE INDEX "twoFactor_userId_idx" ON "two_factor" USING btree ("user_id");
drizzle.config.ts reads ./db/schema.ts, writes migrations to ./migrations/,
and tracks applied migrations in a drizzle_migrations table:
export default defineConfig({
schema: stringschema: './db/schema.ts',
out: stringout: './migrations',
dialect: stringdialect: 'postgresql',
dbCredentials: {
url: string;
}
dbCredentials: { url: stringurl: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinedPOSTGRES_URL! },
migrations: {
table: string;
}
migrations: { table: stringtable: 'drizzle_migrations' },
});
Environment Validation
lib/Env.ts uses @t3-oss/env-nextjs to split variables into server-only,
client-safe (NEXT_PUBLIC_*), and shared blocks. TypeScript enforces at
compile time that client code can't import server-only vars. next.config.ts
imports @/lib/Env at the top level so an invalid environment fails the build
before any code runs:
// lib/Env.ts
export const const env: anyenv = createEnv({
server: {
BETTER_AUTH_SECRET: any;
BETTER_AUTH_URL: any;
POSTGRES_URL: any;
GITHUB_CLIENT_ID: any;
GITHUB_CLIENT_SECRET: any;
GOOGLE_CLIENT_ID: any;
GOOGLE_CLIENT_SECRET: any;
RESEND_API_KEY: any;
}
server: {
type BETTER_AUTH_SECRET: anyBETTER_AUTH_SECRET: z.string().min(1),
type BETTER_AUTH_URL: anyBETTER_AUTH_URL: z.string().url(),
type POSTGRES_URL: anyPOSTGRES_URL: z.string().min(1),
type GITHUB_CLIENT_ID: anyGITHUB_CLIENT_ID: z.string().optional(),
type GITHUB_CLIENT_SECRET: anyGITHUB_CLIENT_SECRET: z.string().optional(),
type GOOGLE_CLIENT_ID: anyGOOGLE_CLIENT_ID: z.string().optional(),
type GOOGLE_CLIENT_SECRET: anyGOOGLE_CLIENT_SECRET: z.string().optional(),
type RESEND_API_KEY: anyRESEND_API_KEY: z.string().optional(),
},
client: {
NEXT_PUBLIC_APP_URL: any;
}
client: {
type NEXT_PUBLIC_APP_URL: anyNEXT_PUBLIC_APP_URL: z.string().url().optional(),
},
shared: {
NODE_ENV: any;
}
shared: {
type NODE_ENV: anyNODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
},
runtimeEnv: {}runtimeEnv: {
/* maps each key to process.env */
},
emptyStringAsUndefined: booleanemptyStringAsUndefined: true,
skipValidation: booleanskipValidation: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnvThe `process.env` property returns an object containing the user environment.
See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html).
An example of this object looks like:
```js
{
TERM: 'xterm-256color',
SHELL: '/usr/local/bin/bash',
USER: 'maciej',
PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
PWD: '/Users/maciej',
EDITOR: 'vim',
SHLVL: '1',
HOME: '/Users/maciej',
LOGNAME: 'maciej',
_: '/usr/local/bin/node'
}
```
It is possible to modify this object, but such modifications will not be
reflected outside the Node.js process, or (unless explicitly requested)
to other `Worker` threads.
In other words, the following example would not work:
```bash
node -e 'process.env.foo = "bar"' && echo $foo
```
While the following will:
```js
import { env } from 'node:process';
env.foo = 'bar';
console.log(env.foo);
```
Assigning a property on `process.env` will implicitly convert the value
to a string. **This behavior is deprecated.** Future versions of Node.js may
throw an error when the value is not a string, number, or boolean.
```js
import { env } from 'node:process';
env.test = null;
console.log(env.test);
// => 'null'
env.test = undefined;
console.log(env.test);
// => 'undefined'
```
Use `delete` to delete a property from `process.env`.
```js
import { env } from 'node:process';
env.TEST = 1;
delete env.TEST;
console.log(env.TEST);
// => undefined
```
On Windows operating systems, environment variables are case-insensitive.
```js
import { env } from 'node:process';
env.TEST = 1;
console.log(env.test);
// => 1
```
Unless explicitly specified when creating a `Worker` instance,
each `Worker` thread has its own copy of `process.env`, based on its
parent thread's `process.env`, or whatever was specified as the `env` option
to the `Worker` constructor. Changes to `process.env` will not be visible
across `Worker` threads, and only the main thread can make changes that
are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner
unlike the main thread.env.string | undefinednpm_lifecycle_event === 'lint',
});
// next.config.ts
import '@/lib/Env'; // build fails here if required vars are missing
const const nextConfig: NextConfignextConfig: type NextConfig = /*unresolved*/ anyNextConfig = {};
export default const nextConfig: NextConfignextConfig;
Proxy — Optimistic Cookie Redirects
proxy.ts is the Next.js 16 replacement for middleware.ts. It uses
getSessionCookie from better-auth/cookies for a fast existence check —
no database call, no network round-trip. The code comments document that
this is intentionally optimistic: real authorization happens in the
server-only DAL on each protected page:
// proxy.ts
import { import getSessionCookiegetSessionCookie } from 'better-auth/cookies';
export function function proxy(request: NextRequest): anyproxy(request: NextRequestrequest: type NextRequest = /*unresolved*/ anyNextRequest) {
const const sessionCookie: anysessionCookie = import getSessionCookiegetSessionCookie(request: NextRequestrequest);
const { const pathname: anypathname } = request: NextRequestrequest.nextUrl;
const const authRoutes: string[]authRoutes = ['/sign-in', '/sign-up'];
const const isAuthRoute: booleanisAuthRoute = const authRoutes: string[]authRoutes.Array<string>.includes(searchElement: string, fromIndex?: number): booleanDetermines whether an array includes a certain element, returning true or false as appropriate.includes(const pathname: anypathname);
// Already signed in — redirect away from auth pages
if (const isAuthRoute: booleanisAuthRoute && const sessionCookie: anysessionCookie) {
return NextResponse.redirect(new var URL: new (url: string | URL, base?: string | URL) => URLThe **`URL`** interface is used to parse, construct, normalize, and encode URL.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)URL('/dashboard', request: NextRequestrequest.url));
}
// Not signed in — redirect away from protected pages
if (!const isAuthRoute: booleanisAuthRoute && !const sessionCookie: anysessionCookie) {
return NextResponse.redirect(new var URL: new (url: string | URL, base?: string | URL) => URLThe **`URL`** interface is used to parse, construct, normalize, and encode URL.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/URL)URL('/sign-in', request: NextRequestrequest.url));
}
return NextResponse.next();
}
export const const config: {
matcher: string[];
}
config = {
matcher: string[]matcher: ['/sign-in', '/sign-up', '/dashboard/:path*'],
};
Server-Only Auth DAL
server/dal/auth.ts is marked "server-only" so any accidental import from
a client component becomes a build error. It wraps auth.api.getSession
and provides two convenience helpers:
// server/dal/auth.ts
import 'server-only';
import { function headers(): Promise<ReadonlyHeaders>This function allows you to read the HTTP incoming request headers in
[Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
[Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations),
[Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and
[Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware).
Read more: [Next.js Docs: `headers`](https://nextjs.org/docs/app/api-reference/functions/headers)headers } from 'next/headers';
import { function redirect(url: string, type?: RedirectType): neverThis function allows you to redirect the user to another URL. It can be used in
[Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
[Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
[Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
- In a Server Component, this will insert a meta tag to redirect the user to the target page.
- In a Route Handler or Server Action, it will serve a 307/303 to the caller.
- In a Server Action, type defaults to 'push' and 'replace' elsewhere.
Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)redirect } from 'next/navigation';
import { import authauth } from '@/lib/auth';
export async function function getSession(): Promise<any>getSession() {
return import authauth.api.getSession({ headers: ReadonlyHeadersheaders: await function headers(): Promise<ReadonlyHeaders>This function allows you to read the HTTP incoming request headers in
[Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
[Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations),
[Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) and
[Middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware).
Read more: [Next.js Docs: `headers`](https://nextjs.org/docs/app/api-reference/functions/headers)headers() });
}
export async function function requireSession(): Promise<any>requireSession() {
const const session: anysession = await function getSession(): Promise<any>getSession();
if (!const session: anysession) function redirect(url: string, type?: RedirectType): neverThis function allows you to redirect the user to another URL. It can be used in
[Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components),
[Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers), and
[Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations).
- In a Server Component, this will insert a meta tag to redirect the user to the target page.
- In a Route Handler or Server Action, it will serve a 307/303 to the caller.
- In a Server Action, type defaults to 'push' and 'replace' elsewhere.
Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect)redirect('/sign-in');
return const session: anysession;
}
export async function function requireUser(): Promise<any>requireUser() {
const const session: anysession = await function requireSession(): Promise<any>requireSession();
return const session: anysession.user;
}
lib/auth-session.ts extends this with admin helpers. requireAdmin redirects
non-admins to the dashboard rather than to sign-in — the user is already
authenticated, they just lack the role. isUserBanned casts the session user to
include the banned field added by the admin plugin:
// lib/auth-session.ts
export async function function requireAdmin(): Promise<Session>requireAdmin(): interface Promise<T>Represents the completion of an asynchronous operationPromise<type Session = /*unresolved*/ anySession> {
const const session: anysession = await requireSession();
if (const session: anysession.user.role !== 'admin') redirect('/dashboard');
return const session: anysession;
}
export async function function isUserBanned(session: Session | null): Promise<boolean>isUserBanned(session: anysession: type Session = /*unresolved*/ anySession | null): interface Promise<T>Represents the completion of an asynchronous operationPromise<boolean> {
if (!session: anysession) return false;
const const user: anyuser = session: anysession.user as type Session = /*unresolved*/ anySession['user'] & { banned?: boolean | undefinedbanned?: boolean };
return const user: anyuser.banned === true;
}
Zod Form Validation
lib/schemas/auth.ts defines a schema for every auth form. They are shared
between the client (instant field feedback) and the server (defense in depth).
The signUpSchema uses .refine() for the password-confirmation cross-field
check; changePasswordSchema adds a second refinement to prevent reusing the
current password:
// lib/schemas/auth.ts
export const const signUpSchema: anysignUpSchema = z
.object({
name: anyname: z.string().min(2).max(100),
email: anyemail: z.string().email(),
password: anypassword: z.string().min(8).max(128),
confirmPassword: anyconfirmPassword: z.string().min(1),
})
.refine((d: anyd) => d: anyd.password === d: anyd.confirmPassword, {
message: stringmessage: 'Passwords do not match',
path: string[]path: ['confirmPassword'],
});
export const const changePasswordSchema: anychangePasswordSchema = z
.object({
currentPassword: anycurrentPassword: z.string().min(1),
newPassword: anynewPassword: z.string().min(8).max(128),
confirmPassword: anyconfirmPassword: z.string().min(1),
})
.refine((d: anyd) => d: anyd.newPassword === d: anyd.confirmPassword, {
message: stringmessage: 'Passwords do not match',
path: string[]path: ['confirmPassword'],
})
.refine((d: anyd) => d: anyd.currentPassword !== d: anyd.newPassword, {
message: stringmessage: 'New password must be different from current password',
path: string[]path: ['newPassword'],
});
Every auth page uses the same pattern: safeParse before calling authClient,
spread error.issues into a field-keyed record, set state, early-return:
// sign-in/page.tsx — pattern used across all auth pages
const const result: anyresult = signInSchema.safeParse({ email: anyemail, password: anypassword });
if (!const result: anyresult.success) {
const const fieldErrors: Record<string, string>fieldErrors: type Record<K extends keyof any, T> = { [P in K]: T; }Construct a type with a set of properties K of type TRecord<string, string> = {};
const result: anyresult.error.issues.forEach((err: anyerr) => {
if (err: anyerr.path[0]) const fieldErrors: Record<string, string>fieldErrors[err: anyerr.path[0] as string] = err: anyerr.message;
});
setErrors(const fieldErrors: Record<string, string>fieldErrors);
setIsLoading(false);
return;
}
const { const error: anyerror } = await authClient.signIn.email(
{ email: anyemail, password: anypassword },
{ onSuccess: () => anyonSuccess: () => router.push('/dashboard') },
);
if (const error: anyerror) setErrors({ form: anyform: const error: anyerror.message || 'Failed to sign in' });
Transactional Email — React Email + Resend
emails/verify-email.tsx and emails/reset-password.tsx are React components
styled with inline style objects. Both export PreviewProps for the React Email
dev server so templates can be iterated without triggering real sends:
// emails/verify-email.tsx
export function function VerifyEmail({ verifyUrl, userName }: VerifyEmailProps): JSX.Element
module VerifyEmail
VerifyEmail({ verifyUrl: VerifyEmailPropsverifyUrl, userName: VerifyEmailPropsuserName }: type VerifyEmailProps = /*unresolved*/ anyVerifyEmailProps) {
return (
<Html>
<Head />
<Body style: anystyle={main}>
<Container style: anystyle={container}>
<Heading style: anystyle={h1}>Verify your email</Heading>
<var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>Hi{userName: VerifyEmailPropsuserName ? ` ${userName: anyuserName}` : ''},</var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>
<var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>Thanks for signing up! Click below to verify your email address:</var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>
<Section style: anystyle={buttonSection}>
<Button style: anystyle={button} href: VerifyEmailPropshref={verifyUrl: VerifyEmailPropsverifyUrl}>
Verify email
</Button>
</Section>
<var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text style: anystyle={link}>{verifyUrl: VerifyEmailPropsverifyUrl}</var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>
<Hr style: anystyle={hr} />
<var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text style: anystyle={footer}>This link will expire in 24 hours.</var Text: {
new (data?: string): Text;
prototype: Text;
}
The **`Text`** interface represents a text Node in a DOM tree.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Text)Text>
</Container>
</Body>
</Html>
);
}
module VerifyEmail
function VerifyEmail({ verifyUrl, userName }: VerifyEmailProps): JSX.Element
VerifyEmail.VerifyEmail.PreviewProps: {
readonly verifyUrl: "https://example.com/verify-email?token=abc123";
readonly userName: "John";
}
PreviewProps = {
verifyUrl: "https://example.com/verify-email?token=abc123"verifyUrl: 'https://example.com/verify-email?token=abc123',
userName: "John"userName: 'John',
} as type const = {
readonly verifyUrl: "https://example.com/verify-email?token=abc123";
readonly userName: "John";
}
const;
The sendAuthEmail helper in lib/auth.ts lazily imports resend only if
RESEND_API_KEY is set. If the key is absent — the default in local dev —
it logs the URL to the console instead of throwing, keeping the full auth
flow functional without any email service configured:
async function function sendAuthEmail(props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}): Promise<void>
sendAuthEmail(props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props: {
action: "reset-password" | "verify-email"action: 'reset-password' | 'verify-email';
email: stringemail: string;
url: stringurl: string;
userName?: string | undefineduserName?: string;
}) {
if (env.RESEND_API_KEY) {
const { const Resend: anyResend } = await import('resend');
const const resend: anyresend = new const Resend: anyResend(env.RESEND_API_KEY);
const const html: anyhtml = await render(
props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.action: "reset-password" | "verify-email"action === 'reset-password'
? ResetPassword({ resetUrl: stringresetUrl: props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.url: stringurl, userName: string | undefineduserName: props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.userName?: string | undefineduserName })
: VerifyEmail({ verifyUrl: stringverifyUrl: props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.url: stringurl, userName: string | undefineduserName: props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.userName?: string | undefineduserName }),
);
await const resend: anyresend.emails.send({
from: stringfrom: 'onboarding@resend.dev',
to: stringto: props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.email: stringemail,
html: anyhtml,
subject: stringsubject: '...',
});
} else {
// Development fallback — no error, just log
var console: Consoleconsole.Console.info(...data: any[]): voidThe **`console.info()`** static method outputs a message to the console at the 'info' log level.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)info(`[better-auth:${props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.action: "reset-password" | "verify-email"action}] Open this URL: ${props: {
action: "reset-password" | "verify-email";
email: string;
url: string;
userName?: string;
}
props.url: stringurl}`);
}
}
Admin Panel
The admin layout uses authClient.admin.hasPermission to check for the
user.list permission before rendering. This is a UX guard — actual security
is enforced in the server-only DAL on each request:
// app/(admin)/admin/layout.tsx
useEffect(() => {
async function function (local function) checkAdmin(): Promise<void>checkAdmin() {
if (!session) {
router.push('/sign-in');
return;
}
const { data: const hasPermission: anyhasPermission } = await authClient.admin.hasPermission({
permissions: {
user: string[];
}
permissions: { user: string[]user: ['list'] },
});
if (!const hasPermission: anyhasPermission) {
router.push('/dashboard');
return;
}
setIsAdmin(true);
setIsLoading(false);
}
function (local function) checkAdmin(): Promise<void>checkAdmin();
}, [session, router]);
The dashboard page (admin/page.tsx) calls listUsers and derives stats
locally — total, active (non-banned), and banned counts — from the returned
array, displayed in four metric Card components with Lucide icons.
The users page (admin/users/page.tsx) renders a searchable Shadcn Table
with email, verification status, ban status, and created-at columns. Each row
has a DropdownMenu for impersonate, ban, and unban. The ban flow nests an
AlertDialog inside DropdownMenuItem — onSelect={(e) => e.preventDefault()}
is the key detail that prevents the dropdown from closing before the dialog opens:
<DropdownMenuItem onSelect: (e: any) => anyonSelect={(e: anye) => e: anye.preventDefault()}>
<AlertDialog>
<AlertDialogTrigger asChild: trueasChild>
<JSX.IntrinsicElements.span: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>span>
<Ban className: stringclassName="mr-2 h-4 w-4" />
Ban User
</JSX.IntrinsicElements.span: DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Ban User</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to ban {user.email}? This will prevent them from signing in.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick: () => anyonClick={() => handleBanUser(user.id)}>Ban User</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuItem>
Impersonation uses a hard window.location.href redirect into the dashboard
rather than router.push, forcing a full page reload so the new session cookie
is picked up immediately:
async function function handleImpersonate(userId: string): Promise<void>handleImpersonate(userId: stringuserId: string) {
const { const data: anydata } = await authClient.admin.impersonateUser({ userId: stringuserId });
if (const data: anydata) var window: Window & typeof globalThisThe **`window`** property of a Window object points to the window object itself.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)window.location: LocationThe **`Window.location`** read-only property returns a Location object with information about the current location of the document.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/location)location.Location.href: stringThe **`href`** property of the Location interface is a stringifier that returns a string containing the whole URL, and allows the href to be updated.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Location/href)href = '/dashboard';
}
Account Settings Page
app/(dashboard)/account/page.tsx uses Shadcn Tabs with four panels:
Profile (read-only), Password, Two-Factor, and Passkeys.
The password tab validates with changePasswordSchema before calling
authClient.changePassword. The 2FA tab uses a Switch that reveals an
inline password-confirmation prompt before enabling or disabling — the prompt
state and loading state are managed separately to allow the cancel button to
work cleanly:
// 2FA toggle — shows password prompt before calling the API
const const handleToggle2FA: () => anyhandleToggle2FA = () => setShowPasswordPrompt(true);
const const handle2FAWithPassword: () => Promise<void>handle2FAWithPassword = async () => {
setIsLoading2FA(true);
if (twoFactorEnabled) {
const { const error: anyerror } = await authClient.twoFactor.disable({ password: anypassword: passwordPromptValue });
if (!const error: anyerror) setTwoFactorEnabled(false);
} else {
const { const error: anyerror } = await authClient.twoFactor.enable({ password: anypassword: passwordPromptValue });
if (!const error: anyerror) setTwoFactorEnabled(true);
}
setShowPasswordPrompt(false);
setPasswordPromptValue('');
setIsLoading2FA(false);
};
The passkeys tab is the UI shell for the passkeyClient already loaded in
auth-client.ts — it renders a KeyRound icon and an "Add Passkey" button
ready to be wired up.
DB Health Check
lib/db-health.ts opens a raw pg Client, races a SELECT 1 against a
configurable timeout, and returns a typed result with an optional recovery
suggestion. The suggestion is derived from the PostgreSQL error code or message:
// lib/db-health.ts
export async function function checkDbHealth(props?: {}): Promise<DbHealthResult>checkDbHealth(props: {}props = {}): interface Promise<T>Represents the completion of an asynchronous operationPromise<type DbHealthResult = /*unresolved*/ anyDbHealthResult> {
const const client: anyclient = new Client({ connectionString: anyconnectionString: url, connectionTimeoutMillis: anyconnectionTimeoutMillis: timeoutMs });
const const timeout: Promise<never>timeout = new var Promise: PromiseConstructor
new <never>(executor: (resolve: (value: PromiseLike<never>) => void, reject: (reason?: any) => void) => void) => Promise<never>
Creates a new Promise.Promise<never>((_: (value: PromiseLike<never>) => void_, reject: (reason?: any) => voidreject) =>
function setTimeout<[]>(callback: () => void, delay?: number): NodeJS.Timeout (+1 overload)[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout)setTimeout(() => reject: (reason?: any) => voidreject(new var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error('connect_timeout')), timeoutMs),
);
try {
await var Promise: PromiseConstructorRepresents the completion of an asynchronous operationPromise.PromiseConstructor.race<[any, Promise<never>]>(values: [any, Promise<never>]): Promise<any> (+1 overload)Creates a Promise that is resolved or rejected when any of the provided Promises are resolved
or rejected.race([const client: anyclient.connect(), const timeout: Promise<never>timeout]);
await const client: anyclient.query('SELECT 1');
await const client: anyclient.end();
return { ok: booleanok: true, message: stringmessage: 'Connected to Postgres' };
} catch (function (local var) error: unknownerror) {
const const code: anycode = getErrorCode(function (local var) error: unknownerror);
const const message: anymessage = getErrorMessage(function (local var) error: unknownerror);
return { ok: booleanok: false, code: anycode, message: anymessage, suggestion: stringsuggestion: function getDbRecoverySuggestion(message: string, code?: string): "Run `docker compose up -d postgres` to start the local database." | "Postgres did not respond in time. Run `docker compose up -d postgres`." | "Check your Postgres credentials in `.env`, then restart with `docker compose up -d postgres`." | "Run `docker compose up -d postgres` and verify `POSTGRES_URL` in `.env`."getDbRecoverySuggestion(const message: anymessage, const code: anycode) };
}
}
function function getDbRecoverySuggestion(message: string, code?: string): "Run `docker compose up -d postgres` to start the local database." | "Postgres did not respond in time. Run `docker compose up -d postgres`." | "Check your Postgres credentials in `.env`, then restart with `docker compose up -d postgres`." | "Run `docker compose up -d postgres` and verify `POSTGRES_URL` in `.env`."getDbRecoverySuggestion(message: stringmessage: string, code: string | undefinedcode?: string) {
if (code: string | undefinedcode === 'ECONNREFUSED' || message: stringmessage.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.includes('ECONNREFUSED'))
return 'Run `docker compose up -d postgres` to start the local database.';
if (message: stringmessage === 'connect_timeout' || /timeout/i.RegExp.test(string: string): booleanReturns a Boolean value that indicates whether or not a pattern exists in a searched string.test(message: stringmessage))
return 'Postgres did not respond in time. Run `docker compose up -d postgres`.';
if (code: string | undefinedcode === '28P01' || /password|authentication/i.RegExp.test(string: string): booleanReturns a Boolean value that indicates whether or not a pattern exists in a searched string.test(message: stringmessage))
return 'Check your Postgres credentials in `.env`, then restart with `docker compose up -d postgres`.';
return 'Run `docker compose up -d postgres` and verify `POSTGRES_URL` in `.env`.';
}
app/api/health/route.ts returns 200 or 503. scripts/check-db.ts
is the CLI entry point — it calls checkDbHealth, prints the JSON result,
prints the suggestion if unhealthy, and exits with code 1 on failure. The
"test" script in package.json runs this as a lightweight smoke test.
Marketing Landing Page
app/page.tsx is a client component composed of six sections — Hero,
Features, How It Works, Pricing, CTA, and Footer — using Magic UI's
RetroGrid, TextAnimate, WordRotate, TypingAnimation, AnimatedList,
MagicCard, BentoGrid, BentoCard, and ShinyButton.
The hero detects session state with authClient.useSession(). While pending
it shows a spinner; authenticated users see their avatar, a "Go to Dashboard"
ShinyButton, and a sign-out button; guests get "Get Started" and "Sign In":
// app/page.tsx — HeroSection
function function HeroSection(): JSX.ElementHeroSection() {
const { data: const session: anysession, const isPending: anyisPending } = authClient.useSession();
const const user: anyuser = const session: anysession?.user;
return (
<JSX.IntrinsicElements.section: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>section HTMLAttributes<HTMLElement>.className?: string | undefinedclassName="relative flex min-h-[100dvh] flex-col items-center justify-center overflow-hidden">
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="absolute inset-0 z-0">
<RetroGrid className: stringclassName="opacity-30" angle: numberangle={65} cellSize: numbercellSize={60} />
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="absolute inset-0 bg-gradient-to-b from-transparent to-background" />
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="relative z-10 flex max-w-5xl flex-col items-center text-center">
<JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
<TextAnimate animation: stringanimation="slideUp">Build Amazing</TextAnimate>
<WordRotate
words: string[]words={['Products', 'Platforms', 'Solutions', 'Dreams']}
className: stringclassName="bg-gradient-to-r from-primary via-purple-500 to-pink-500 bg-clip-text text-transparent"
/>
</JSX.IntrinsicElements.h1: DetailedHTMLProps<HTMLAttributes<HTMLHeadingElement>, HTMLHeadingElement>h1>
<TypingAnimation
words: string[]words={['building startups', 'creating SaaS', 'launching products']}
loop: booleanloop={true}
/>
{const isPending: anyisPending ? (
<Spinner />
) : const user: anyuser ? (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="flex flex-col items-center gap-4">
<Avatar>
<AvatarImage src: anysrc={const user: anyuser.image || ''} />
</Avatar>
<Link href: stringhref="/dashboard">
<ShinyButton>Go to Dashboard</ShinyButton>
</Link>
<Button onClick: () => anyonClick={() => authClient.signOut()}>Sign Out</Button>
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
) : (
<JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div HTMLAttributes<T>.className?: string | undefinedclassName="flex gap-4">
<Link href: stringhref="/sign-up">
<ShinyButton>Get Started</ShinyButton>
</Link>
<Link href: stringhref="/sign-in">
<ShinyButton>Sign In</ShinyButton>
</Link>
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
)}
</JSX.IntrinsicElements.div: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>div>
</JSX.IntrinsicElements.section: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>section>
);
}
FeaturesSection uses BentoGrid with six feature cards; HowItWorksSection
uses AnimatedList + MagicCard for a four-step process with spring
animations; PricingSection renders three tiers with the popular one scaled
up at md:scale-105; CTASection repeats the RetroGrid background with a
final call-to-action.
Theme Provider
components/theme-provider.tsx wraps next-themes and registers a D-key
keyboard shortcut that toggles dark/light mode. The isTypingTarget guard
prevents the toggle from firing when focus is inside an INPUT, TEXTAREA,
SELECT, or any contentEditable element:
// components/theme-provider.tsx
function function ThemeHotkey(): nullThemeHotkey() {
const { const resolvedTheme: anyresolvedTheme, const setTheme: anysetTheme } = useTheme();
useEffect(() => {
function function (local function) onKeyDown(event: KeyboardEvent): voidonKeyDown(event: KeyboardEventevent: KeyboardEvent) {
if (event: KeyboardEventevent.Event.defaultPrevented: booleanThe **`defaultPrevented`** read-only property of the Event interface returns a boolean value indicating whether or not the call to Event.preventDefault() canceled the event.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/defaultPrevented)defaultPrevented || event: KeyboardEventevent.KeyboardEvent.repeat: booleanThe **`repeat`** read-only property of the `true` if the given key is being held down such that it is automatically repeating.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/repeat)repeat) return;
if (event: KeyboardEventevent.KeyboardEvent.metaKey: booleanThe **`KeyboardEvent.metaKey`** read-only property returning a boolean value that indicates if the <kbd>Meta</kbd> key was pressed (`true`) or not (`false`) when the event occurred.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/metaKey)metaKey || event: KeyboardEventevent.KeyboardEvent.ctrlKey: booleanThe **`KeyboardEvent.ctrlKey`** read-only property returns a boolean value that indicates if the <kbd>control</kbd> key was pressed (`true`) or not (`false`) when the event occurred.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/ctrlKey)ctrlKey || event: KeyboardEventevent.KeyboardEvent.altKey: booleanThe **`KeyboardEvent.altKey`** read-only property is a boolean value that indicates if the <kbd>alt</kbd> key (<kbd>Option</kbd> or <kbd>⌥</kbd> on macOS) was pressed (`true`) or not (`false`) when the event occurred.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/altKey)altKey) return;
if (event: KeyboardEventevent.KeyboardEvent.key: stringThe KeyboardEvent interface's **`key`** read-only property returns the value of the key pressed by the user, taking into consideration the state of modifier keys such as <kbd>Shift</kbd> as well as the keyboard locale and layout.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/KeyboardEvent/key)key.String.toLowerCase(): stringConverts all the alphabetic characters in a string to lowercase.toLowerCase() !== 'd') return;
if (isTypingTarget(event: KeyboardEventevent.Event.target: EventTarget | nullThe read-only **`target`** property of the dispatched.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target)target)) return;
const setTheme: anysetTheme(const resolvedTheme: anyresolvedTheme === 'dark' ? 'light' : 'dark');
}
var window: Window & typeof globalThisThe **`window`** property of a Window object points to the window object itself.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)window.addEventListener<"keydown">(type: "keydown", listener: (this: Window, ev: KeyboardEvent) => any, options?: boolean | AddEventListenerOptions): void (+1 overload)The **`addEventListener()`** method of the EventTarget interface sets up a function that will be called whenever the specified event is delivered to the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener)addEventListener('keydown', function (local function) onKeyDown(event: KeyboardEvent): voidonKeyDown);
return () => var window: Window & typeof globalThisThe **`window`** property of a Window object points to the window object itself.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/window)window.removeEventListener<"keydown">(type: "keydown", listener: (this: Window, ev: KeyboardEvent) => any, options?: boolean | EventListenerOptions): void (+1 overload)The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener)removeEventListener('keydown', function (local function) onKeyDown(event: KeyboardEvent): voidonKeyDown);
}, [const resolvedTheme: anyresolvedTheme, const setTheme: anysetTheme]);
return null;
}
globals.css uses Tailwind v4's CSS-first configuration — all design tokens
are oklch() CSS custom properties under :root and .dark, then mapped to
Tailwind theme tokens in the @theme inline block, with sidebar tokens and
chart palette variables included alongside the core set:
:root {
--primary: oklch(0.205 0 0);
--background: oklch(1 0 0);
--destructive: oklch(0.58 0.22 27);
--radius: 0.625rem;
}
.dark {
--primary: oklch(0.87 0 0);
--background: oklch(0.145 0 0);
}
@theme inline {
--color-primary: var(--primary);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-lg: var(--radius);
}
Developer Toolchain
Lefthook enforces three quality gates before every push and validates every commit message via Commitlint with conventional-commits rules:
# lefthook.yml
pre-push:
commands:
"run tests": { run: bun run test:vitest:run }
"check types": { run: bun run typecheck }
"knip": { run: bun run knip }
commit-msg:
commands:
"lint commit message": { run: bunx commitlint --edit {1} }
Knip is configured to ignore generated Better Auth schema files, all UI components, DAL helpers, and several tooling packages that only appear in scripts or config:
// knip.jsonc
{
"ignore": [
"db/better-auth-schema*.ts",
"lib/dal/**",
"components/ui/**",
"lib/auth-session.ts",
"lib/auth.ts",
"lib/schemas/**",
],
"ignoreDependencies": ["@better-auth/cli", "playwright", "lefthook", "commitlint"],
}
Vitest uses the @ path alias to match Next.js's resolution, runs in the
Node.js environment, and collects V8 coverage:
// vitest.config.ts
export default defineConfig({
test: {
globals: boolean;
environment: string;
coverage: {
provider: string;
reporter: string[];
};
}
test: {
globals: booleanglobals: true,
environment: stringenvironment: 'node',
coverage: {
provider: string;
reporter: string[];
}
coverage: { provider: stringprovider: 'v8', reporter: string[]reporter: ['text', 'json', 'html'] },
},
resolve: {
alias: {
'@': any;
};
}
resolve: {
alias: {
'@': any;
}
alias: { '@': path.resolve(var __dirname: stringThe directory name of the current module. This is the same as the
`path.dirname()` of the `__filename`.__dirname, './') },
},
});
Docker Compose runs PostgreSQL 18 with pg_stat_statements loaded via a
custom Dockerfile.postgres. scripts/check-db.ts calls checkDbHealth and
exits 0 or 1, making it usable in CI pipelines:
// scripts/check-db.ts
async function function main(): Promise<void>main() {
const const res: anyres = await checkDbHealth({ timeoutMs: numbertimeoutMs: 1500 });
var console: Consoleconsole.Console.log(...data: any[]): voidThe **`console.log()`** static method outputs a message to the console.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(var JSON: JSONAn intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.JSON.JSON.stringify(value: any, replacer?: (number | string)[] | null, space?: string | number): string (+1 overload)Converts a JavaScript value to a JavaScript Object Notation (JSON) string.stringify(const res: anyres, null, 2));
if (!const res: anyres.ok && const res: anyres.suggestion) var console: Consoleconsole.Console.log(...data: any[]): voidThe **`console.log()`** static method outputs a message to the console.
[MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)log(`// ${const res: anyres.suggestion}`);
if (!const res: anyres.ok) var process: NodeJS.Processprocess.NodeJS.Process.exit(code?: number | string | null): neverThe `process.exit()` method instructs Node.js to terminate the process
synchronously with an exit status of `code`. If `code` is omitted, exit uses
either the 'success' code `0` or the value of `process.exitCode` if it has been
set. Node.js will not terminate until all the `'exit'` event listeners are
called.
To exit with a 'failure' code:
```js
import { exit } from 'node:process';
exit(1);
```
The shell that executed Node.js should see the exit code as `1`.
Calling `process.exit()` will force the process to exit as quickly as possible
even if there are still asynchronous operations pending that have not yet
completed fully, including I/O operations to `process.stdout` and `process.stderr`.
In most situations, it is not actually necessary to call `process.exit()` explicitly. The Node.js process will exit on its own _if there is no additional_
_work pending_ in the event loop. The `process.exitCode` property can be set to
tell the process which exit code to use when the process exits gracefully.
For instance, the following example illustrates a _misuse_ of the `process.exit()` method that could lead to data printed to stdout being
truncated and lost:
```js
import { exit } from 'node:process';
// This is an example of what *not* to do:
if (someConditionNotMet()) {
printUsageToStdout();
exit(1);
}
```
The reason this is problematic is because writes to `process.stdout` in Node.js
are sometimes _asynchronous_ and may occur over multiple ticks of the Node.js
event loop. Calling `process.exit()`, however, forces the process to exit _before_ those additional writes to `stdout` can be performed.
Rather than calling `process.exit()` directly, the code _should_ set the `process.exitCode` and allow the process to exit naturally by avoiding
scheduling any additional work for the event loop:
```js
import process from 'node:process';
// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
printUsageToStdout();
process.exitCode = 1;
}
```
If it is necessary to terminate the Node.js process due to an error condition,
throwing an _uncaught_ error and allowing the process to terminate accordingly
is safer than calling `process.exit()`.
In `Worker` threads, this function stops the current thread rather
than the current process.exit(1);
}
void function main(): Promise<void>main();
Roadmap
The implementation follows the 11-goal roadmap in ROADMAP.md. Goals 1–5
are complete: foundation and tooling, database, Better Auth core, auth UI,
and admin. The remaining goals are staged as vertical slices:
Goal 6 — Shadcn UI foundation: shells, standardized forms, dialogs, and feedback states across all flows.
Goal 7 — Stripe billing: subscriptions, Checkout, customer portal, webhook-driven local billing state, feature gating.
Goal 8 — oRPC: typed API procedures for business logic, server caller for RSC, browser client.
Goal 9 — i18n with next-intl and locale-aware routes under app/[locale].
Goal 10 — Sentry, Better Stack, PostHog, Arcjet, Checkly.
Goal 11 — CI/CD with GitHub Actions, semantic-release, Dependabot, and deployment guides.