Skip to main content

SaaS Boilerplate

Home/projects/SaaS Boilerplate

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

Next.js 16TypeScriptBetter AuthDrizzle ORMPostgreSQLTailwind CSS v4Shadcn UIMagic UIZodReact EmailResendBunVitest
View repository

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.
@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
env
.string | undefinedNEXT_PUBLIC_APP_URL
? `${var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.ProcessEnv
The `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"' &#x26;&#x26; 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.
@sincev0.1.27
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.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): boolean
Determines whether an array includes a certain element, returning true or false as appropriate.
@paramsearchElement The element to search for.@paramfromIndex The position in this array at which to begin searching for searchElement.
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) => URL
The **`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) => URL
The **`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): never
This 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): never
This 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 operation
Promise
<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 operation
Promise
<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 T
Record
<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[]): void
The **`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 DropdownMenuItemonSelect={(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 globalThis
The **`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: Location
The **`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: string
The **`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 operation
Promise
<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.
@paramexecutor A callback used to initialize the promise. This callback is passed two arguments: a resolve callback used to resolve the promise with a value or the result of another promise, and a reject callback used to reject the promise with a provided reason or error.
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: PromiseConstructor
Represents the completion of an asynchronous operation
Promise
.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.
@paramvalues An array of Promises.@returnsA new Promise.
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): boolean
Returns 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.
@paramsearchString search string@paramposition If position is undefined, 0 is assumed, so as to search all of the String.
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): boolean
Returns a Boolean value that indicates whether or not a pattern exists in a searched string.
@paramstring String on which to perform the search.
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): boolean
Returns a Boolean value that indicates whether or not a pattern exists in a searched string.
@paramstring String on which to perform the search.
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: boolean
The **`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: boolean
The **`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: boolean
The **`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: boolean
The **`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: boolean
The **`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: string
The 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(): string
Converts all the alphabetic characters in a string to lowercase.
toLowerCase
() !== 'd') return;
if (isTypingTarget(event: KeyboardEventevent.Event.target: EventTarget | null
The 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 globalThis
The **`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 globalThis
The **`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: string
The directory name of the current module. This is the same as the `path.dirname()` of the `__filename`.
@sincev0.1.27
__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[]): void
The **`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: JSON
An 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.
@paramvalue A JavaScript value, usually an object or array, to be converted.@paramreplacer An array of strings and numbers that acts as an approved list for selecting the object properties that will be stringified.@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.@throws{TypeError} If a circular reference or a BigInt value is found.
stringify
(const res: anyres, null, 2));
if (!const res: anyres.ok && const res: anyres.suggestion) var console: Consoleconsole.Console.log(...data: any[]): void
The **`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): never
The `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.
@sincev0.1.13@paramcode The exit code. For string type, only integer strings (e.g.,'1') are allowed.
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.