Stack
| Layer | Tech | Why |
|---|---|---|
| Framework | Next.js 15 (App Router, RSC, Turbopack) | Server-first rendering, mature ecosystem. |
| Runtime | React 19 | Async server components, use() hook. |
| Language | TypeScript 5 (strict) | Catches the wrong shape before it ships. |
| Styling | Tailwind v4 + shadcn/ui | OKLCH colors, CSS-first config in globals.css. |
| ORM | Drizzle | Typed schema, no codegen, raw-SQL escape hatch. |
| Database | Postgres only (v0.1) | One source of truth, transactions, JSONB. |
| Auth | Better Auth + Drizzle adapter | Server-side sessions, OAuth, email verification. |
| i18n | next-intl | Bilingual EN/ZH with as-needed URL prefix. |
| Docs | Fumadocs MDX | Bilingual *.zh.mdx with locale-aware lookup. |
| Payments | Stripe / Paddle / Lemon Squeezy / Creem | Behind a single facade. |
| AI | Mock / OpenRouter / OpenAI / Anthropic / Replicate / fal | Phase 2; same facade pattern. |
| Resend + React Email | Templated, type-safe, dev-rendered. |
Directory tree
app/ (inside src/) is the route surface. src/ modules outside app/ are the
business logic that pages and route handlers call. The app/[locale]/ segment is
where i18n routing happens; the route groups (marketing), (auth), (app)
exist for layout grouping and auth gating, not for URL shape.
Load-bearing decisions
Provider facade pattern
Every module that touches an external service exports a single object (the facade) backed by interchangeable providers. The facade picks an implementation at boot fromsiteConfig.<module>.provider. Consumers see only
the facade.
src/ai/, src/mail/, src/newsletter/, src/customer-service/,
src/affiliate/. Adding Postmark for mail is one new file under
src/mail/provider/postmark.ts and one switch arm — no consumer code changes.
Server-only enforcement
Files that touch the DB or read secretsimport 'server-only' at the top. Next.js
fails the build if such a module is imported into a client component. Already
protected: src/db/index.ts, src/lib/auth.ts, src/lib/server.ts,
src/lib/safe-action.ts, every src/payment/provider/*, src/mail/index.ts,
src/credits/index.ts, src/credits/server.ts. Add the line whenever you create
a new server-only module — it’s a half-second of typing that prevents accidental
key leaks.
Three-tier server actions
All mutating actions go throughnext-safe-action with one of three clients
defined in src/lib/safe-action.ts:
actionClient— public, no auth required.userActionClient— gated; providesctx.userfrom the session.adminActionClient— gated; requiresuser.role === 'admin'.
if (!user) throw boilerplate scattered
across the codebase.
Idempotent webhooks
Payment webhooks retry on any non-2xx response. If you double-grant credits on a retry, you’re paying twice for one payment. The scaffold solves this with two guarantees insrc/payment/handlers/:
payment.invoiceIdhas a unique index — duplicate inserts fail the transaction.- Every handler does a
payment.sessionIdlookup before insert and exits early on hit.
Split DB schemas
src/db/ is split by concern: auth.schema.ts (Better Auth), app.schema.ts
(payment + credits + transactions), affiliate.schema.ts, ai.schema.ts,
license.schema.ts. schema.ts re-exports them all for Drizzle Kit and Better
Auth’s adapter. IDs are text (nanoid prefix or snowflake) — never serial,
because text ids let you migrate between providers without breaking foreign keys
and don’t leak user-count via enumeration. All foreign keys cascade on user
delete (GDPR-friendly out of the box).
Bilingual MDX
Every doc lives in two parallel files:path/to/doc.mdx and path/to/doc.zh.mdx.
Fumadocs is configured to look up the locale-suffixed file first, falling back to
the default. The same pattern works for blog posts and changelog entries. Both
locales must exist for a doc to be considered shipped.
Locale routing — as-needed prefix
src/i18n/routing.ts configures next-intl to keep English at the root (/about)
and prefix Chinese (/zh/about). Use Link / useRouter from
@/i18n/navigation everywhere — they preserve the locale prefix automatically.
Importing from next/link or next/navigation directly will silently lose the
locale on client navigations.
Cost in micro-cents
AI inference and credit math both deal in fractions of a cent. Storing money as floats invites rounding bugs; storing as integer cents loses sub-cent precision (a million-token Claude call costs $0.000003 / token). The scaffold stores everything as integer micro-cents (1/1,000,000 of a USD) and converts to display units at the edge. Seesrc/ai/pricing.ts for the math.
Edge runtime — only for OG image
src/app/opengraph-image.tsx runs on the Edge runtime because it’s the only path
where cold-start latency directly hurts user-visible perf (social-card scrapers
time out fast). Everything else — including all API routes that touch Postgres —
uses the Node runtime, because the pg driver and many auth dependencies
need full Node APIs. Don’t move routes to Edge unless you know what you’re
trading away.
Data flow — checkout
A canonical flow that exercises most of the architecture:See also
- Configuration —
siteConfigknobs. - Customization — common recipes.
- Vercel deployment — ship the architecture.
- Env reference — every key that selects a provider.