zazzy
Architecture

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:

  1. 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.
  2. 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 @feature markers.
  3. Declared dependenciesfeatures/x may not import from features/y unless x declares y in its requires in features.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):

  1. Whole paths — each feature lists its directories and route groups; deselected features are deleted outright.
  2. Marker regions — the cross-cutting touchpoints above, removed region by region.
  3. Structured rewrites — things comments can't mark: package.json deps/scripts, .env.example vars, docker-compose.yml services, and features.json itself.

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:

TierModels
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) → optional entitled('key') / checkQuota() → Prisma.
  • Public APIcurl/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 in features/*/jobs/ (retries, steps, crons) → service layer. Stripe webhooks verify the signature, persist StripeEvent for idempotency, then hand the event to Inngest.

On this page