Architecture
The pruning model, @feature markers, and the feature DAG.
The starter is one full, always-runnable reference app (apps/web) with
every feature turned on, plus a CLI (packages/cli) that generates new
projects by copying that app and pruning the features you didn't pick.
Because the template is the running reference app — it has tests and is
exercised by CI — the template can't drift from working code. Pruning is driven
by a single manifest, features.json, plus
@feature marker comments at the points where features touch shared code.
Where this comes from
This page mirrors the repo's own docs/ARCHITECTURE.md. Per-feature detail
lives in each feature guide; the mechanics of the prune
engine are in the CLI section.
Repo layout
saas-starter/
apps/web/ # the kitchen-sink reference app (= the template)
prisma/schema.prisma # all models; prunable models wrapped in @feature markers
src/
app/
(marketing)/ # public site — owned by `marketing`
(auth)/ # sign-in/up, magic-link, verify — owned by `auth`
(app)/ # protected product shell: dashboard, settings, org pages
admin/ # back-office — owned by `admin`
api/
trpc/[trpc]/ # tRPC handler
inngest/ # jobs endpoint — owned by `jobs`
webhooks/stripe/ # owned by `billing`
v1/ # public API — owned by `api-keys`
features/<name>/ # feature modules (see boundary rules below)
components/ server/ emails/ jobs/ lib/
server/ # cross-cutting server core: trpc.ts, db.ts, auth.ts
components/ # shared UI (shadcn/ui primitives, app shell)
config/plans.ts # plans/prices/entitlements — owned by `billing`
env.ts # zod-validated env
docker-compose.yml # postgres, inngest dev, minio (feature-marked services)
packages/cli/ # create-* scaffolder + prune engine
features.json # feature manifest: requires DAG + prune rules (single source of truth)
docs/ # repo docs (this site lives in apps/docs)Why a single Next.js app and not packages/{db,api,ui}: the CLI's output
has to be a plain single-app project — nobody wants a monorepo for a fresh idea
— and pruning across package boundaries multiplies the marker surface. Module
boundaries are enforced inside the app by directory convention plus an ESLint
boundary rule instead.
Feature modules
Every feature lives in src/features/<name>/, plus a route group it owns where
it needs one. Three rules keep features cleanly separable:
- Whole-path deletability — all of a feature's code lives in its directory or its owned routes/models, so it can be removed as whole paths.
- Marked touchpoints — anything a feature must touch outside its
directory (nav entries, providers, Prisma relations, env examples, compose
services) is a touchpoint and is wrapped in
@featuremarkers. - Declared dependencies —
features/xmay not import fromfeatures/yunlessxdeclaresyin itsrequiresinfeatures.json(an ESLint boundary rule enforces this).
@feature markers
A marker region brackets a stretch of shared code that belongs to one feature.
When that feature is pruned, the region — and only the region — is deleted; when
it's kept, the markers are stripped and the code ships verbatim. They exist in
comment syntaxes for every file type the template uses (//, {/* */}, #
for TS/JSX/env/YAML/Prisma). A nav link owned by billing, for example:
{/* @feature:billing:start */}
<Link href="/settings/billing">Billing</Link>
{/* @feature:billing:end */}Regions are keyed by the feature that owns the touchpoint, not the file
around it. The per-plan storage-limit block inside the files feature carries a
flags marker and disappears when flags is off, even though the file it lives
in stays. A repo lint script (pnpm lint:markers) verifies every region is
balanced and names a known feature on every PR, so an unmarked cross-feature
edit fails CI.
Docs pages carry no markers
This site describes features; its pages are never conditional on them, so
the marker lint's scope stays apps/web. The docs app is repo infrastructure,
never copied into a generated project.
The pruning model
features.json drives three prune layers (full spec in
the prune model):
- Whole paths — each feature lists its directories and route groups; deselected features are deleted outright.
- Marker regions — the cross-cutting touchpoints above, removed region by region.
- Structured rewrites — things comments can't mark:
package.jsondeps/scripts,.env.examplevars,docker-compose.ymlservices, andfeatures.jsonitself.
After pruning, the CLI runs Prettier, prisma format, and tsc --noEmit, and
fails loudly if the result doesn't compile — a pruned app that doesn't typecheck
is a bug in the markers, never something the generator papers over.
The feature dependency DAG
requires in features.json records the graph below; the CLI resolves it
transitively and auto-selects the dependencies of anything you pick.
auth (core) ─→ orgs (core)
jobs ─→ email
orgs + email + jobs ─→ billing ─→ flags ─→ usage
orgs ─→ api-keys, files, audit-log (usage/flags integrations are soft markers)
billing ─→ marketing (pricing page)
orgs + billing + flags ─→ admin
observability (requires nothing; wires into jobs/api-keys/billing via soft markers)
agents (dev-time tooling; requires nothing)Soft integrations — files' per-plan limits, api-keys' usage attribution,
observability's hooks into jobs/billing — are marker regions, not
dependencies. They vanish independently when the other feature is pruned,
without forcing it to be present. See each feature guide for
its own requires line and soft seams.
Data model overview
Full definitions live in each feature guide; the shape at a glance:
| Tier | Models |
|---|---|
| Core (never pruned) | User, Session, Account, Verification (Better Auth) · Organization, Membership, Invite |
| Prunable (owner in parens) | Subscription, StripeEvent (billing) · UsageEvent, UsageRollup (usage) · ApiKey + rate-limit store (api-keys) · FlagOverride (flags) · AuditLog (audit-log) · File (files) |
Rules that hold across the schema:
- Every tenant-scoped model carries
organizationId— tenancy hangs off the org, never the user. - Plans and prices live in
config/plans.ts, not the database, synced to Stripe by script, so pricing stays in git and entitlements are fully typed. - A relation field crossing a feature boundary sits inside marker regions on both models, so a pruned model never leaves a dangling relation.
Request lifecycles
Every inbound request gets a request id (from x-request-id or generated —
core behavior, independent of observability) carried through AsyncLocalStorage
and echoed in responses and error payloads.
- Web session — browser → Next.js
(app)route → tRPC procedure →protectedProcedure(Better Auth session) →orgProcedure(active org + membership/role) → optionalentitled('key')/checkQuota()→ Prisma. - Public API —
curl→/api/v1/*handler → API-key auth (hash lookup, scope check) → Postgres rate limit → the same service-layer functions the tRPC procedures call → response, usage attributed to the key's org + key id. - Async — any server context →
inngest.send(event)→ an Inngest function infeatures/*/jobs/(retries, steps, crons) → service layer. Stripe webhooks verify the signature, persistStripeEventfor idempotency, then hand the event to Inngest.
Get the authenticated organization's usage summary GET
Returns the authenticated organization's current-period usage for every metered metric — the amount used, the plan limit (`null` when the metric is unmetered or the plan is unlimited), and the remaining headroom. Served through the same `getUsageSummary` service the in-app usage dashboard reads, so the API and the dashboard always agree. Requires the `read` scope.
Deployment
Vercel, compose services, and the env matrix.