---
title: "Product Architecture Standard"
description: "One Worker, one deploy, one domain — the canonical repo structure every product on this stack starts from."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.michaelchen.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Architecture Standard

# Product Architecture Standard

A reference for how these products are structured: **one Cloudflare Worker,
one deploy, one domain — serving three surfaces** (a React SPA, a JSON API, and
a static marketing/docs site) that share a typed product config, one set of
design tokens, and an internal app contract.

**Standard version: 1.6** — changelog in §14.

This document is **normative**: it fixes the stack, the top-level layout, how
the three surfaces share code, and how one Worker serves and builds them.
Product repos do **not** copy it — they record which version they conform to
and pin it (§14). Implementation walkthroughs live in the guides:
[tenant scoping](/guides/tenant-scoping/) and
[bootstrapping a product](/guides/bootstrap/).

**Scope: repository structure.** The Worker's *internal* behavioral
conventions — error handling, testing depth, env validation, auth/tenancy
beyond the structural rules below — vary across products and are a later phase
(§10–§12).

---

## 1. Principles

1. **One Worker, one deploy, one origin.** No CORS, no cross-service auth, no
   URL migration between "the app" and "the site." Everything ships in a single
   `wrangler deploy`. Static assets are served by the Workers asset pipeline;
   only `/api/*` and unmatched fallbacks invoke Worker code.

2. **Shared by scope, not by convenience.** Three things cross module
   boundaries, and each lives where its *consumers* are:
- **`config/`** (top level) — typed product facts, consumed by all three
     surfaces.
- **`styles/`** (top level) — design tokens (CSS), consumed by the SPA and
     marketing.
- **`src/shared/`** — the app's internal type contract, consumed only by
     the SPA and Worker.

   The rule: if a value could appear on the pricing page it's `config/`; if it
   only describes the API/domain it's `src/shared/`. See §5.

3. **Static by default, dynamic only where needed.** Marketing, pricing,
   legal, blog, and docs are pre-built static HTML (Astro). The SPA is a
   static bundle. The Worker is invoked only for the API and for serving the
   right HTML shell on a fallback path.

4. **Two isolated TypeScript projects for the app.** Client code (DOM libs)
   and Worker code (workerd types) have incompatible global environments. They
   are compiled by separate `tsconfig`s that share only `src/shared`.

5. **The Worker splits HTTP from logic from data access.** Route files
   (`routes/`) do HTTP; business logic lives in `services/` as plain
   functions; data access lives in `db/` behind per-request scoped accessors
   constructed by each route family's middleware (§4).

---

## 2. Top-level repo layout

```
<product>/
├── config/                   # cross-surface product FACTS (typed data)
│   ├── brand.ts              #   product name, site origin, support email
│   └── routes.ts             #   app route paths + isAppPath() — the single
│                             #   declaration the Worker, SPA, and marketing
│                             #   site all read (see §5)
├── styles/                   # cross-surface design tokens (CSS)
│   └── tokens.css            #   custom properties + Tailwind @theme
├── src/                      # the app: SPA + Worker + internal contract
│   ├── client/               #   React SPA (browser)
│   ├── worker/               #   Cloudflare Worker (API + serving)
│   └── shared/               #   app-internal types both app halves import
├── marketing/                # separate Astro project (static site + docs)
│   └── public/               #   second asset root — see rule below
├── migrations/               # D1 SQL migrations, numbered 0001_*.sql …
├── scripts/                  # build/ops scripts (merge-marketing, seeds, …)
├── test/                     # vitest suites (workers pool)
├── public/                   # app static files — reserved prefix only
├── docs/                     # product-local design specs and plans
│
├── app.html                  # SPA HTML shell (NOT index.html — see §7)
├── seed.sql                  # D1 seed data (npm run db:seed)
├── vite.config.ts            # builds the SPA + bundles the Worker
├── wrangler.jsonc            # Worker + bindings (D1, DO, AI, assets, vars)
├── worker-configuration.d.ts # generated by `wrangler types` — never hand-edited
├── tsconfig.json             # client project (DOM libs)
├── tsconfig.worker.json      # worker project (generated runtime types, no DOM)
├── components.json           # shadcn generator config
├── package.json              # root; declares `marketing` as a workspace
└── README.md
```

**Two `public/` directories land in the same deployed asset root.** Keep them
disjoint **by construction**: the app's `public/` may only contain files under
a reserved prefix (`/embed/*`, `/widget/*` — the product's embed surface);
marketing owns the root namespace (favicon, fonts, generated robots/sitemap).
The merge guard in §8 is the backstop, not the rule.

The mental model: **`src/` is the application, `marketing/` is the website**,
and they meet only in the merged build output, the shared tokens (`styles/`),
and the shared product config (`config/`).

---

## 3. Surface A — the React SPA (`src/client`)

The authenticated app and all dynamic guest pages. A static SPA bundle; the
Worker never renders React.

```
src/client/
├── main.tsx                  # entry: mounts providers (Router, i18n, Toast)
├── App.tsx                   # <Routes> — paths come from config/routes.ts
├── api.ts                    # thin typed API client (one `call<T>()` helper)
├── i18n.tsx                  # i18n provider + typed message dictionary
├── realtime.ts               # WebSocket client for live updates (if used)
├── styles.css                # imports shared tokens, then app styles
├── pages/                    # one folder/file per route
├── components/
│   ├── ui/                   #   shadcn primitives — the generator default
│   ├── <Feature>.tsx         #   composed feature components
│   └── skeletons.tsx
└── lib/                      # cn(), small helpers (shadcn convention)
```

**Rules**

- `App.tsx` composes the routes; the *paths* come from `config/routes.ts` so
  the Worker's shell-vs-404 decision (§7) and marketing's CTAs can't drift.
- `api.ts` is the **only** place `fetch` talks to the API: one `call<T>()`
  wrapper that sets credentials, parses JSON, and throws a typed
  `ApiFailure(status, code)` on non-2xx. Request/response types come from
  `src/shared`, never redefined.
- `components/ui/` keeps the shadcn generator default (`components.json`
  ships `ui: "@/components/ui"`) — do not rename it. Edits to its files stay
  theme/behavior-level; feature composition lives in feature components. No
  sibling `ui.tsx` next to the `ui/` directory.
- User-facing text goes through `i18n.tsx`; the dictionary is a typed object
  so a missing key is a compile error.

---

## 4. Surface B — the Worker (`src/worker`)

`/api/*` handlers, the cron entry, Durable Objects, and the HTML-shell
fallback. Hono for routing; plain functions for logic.

```
src/worker/
├── index.ts                  # entry: composes routes, asset fallback,
│                             #   onError, scheduled() cron, DO re-exports
├── realtime.ts               # Durable Object class (e.g. a per-tenant hub)
├── routes/                   # HTTP layer — one Hono sub-app per area
│   ├── middleware.ts         #   scope-injecting middlewares (requireAuth, …)
│   ├── public.ts             #   guest API
│   ├── admin.ts              #   authenticated API
│   └── <integration>.ts      #   webhooks, OAuth callbacks
├── services/                 # business logic — plain functions (repo, args)
│   ├── auth.ts               #   session/authorization
│   ├── <domain>.ts           #   one module per domain area
│   └── util.ts               #   ApiError + tiny shared primitives — keep small
└── db/                       # data access — the tenant chokepoint
├── scope.ts              #   forTenant() + forTenantAsStaff() factories
├── global.ts             #   explicitly-unscoped queries (sessions, config)
└── <domain>.ts           #   tenant-scoped queries (via scope.ts factories)
```

**The entry file (`index.ts`) is the spine.** It creates one
`Hono<{ Bindings: Env }>` app, mounts each route module (most specific prefix
first), defines the catch-all that serves the correct HTML shell (§7),
registers one central `onError`, exports `{ fetch, scheduled }` (cron work
wrapped in `ctx.waitUntil`), and re-exports Durable Object classes.

**Structural rules:**

- HTTP in `routes/`, business logic in `services/`, data access in `db/`.
  There is no separate handler layer (a Hono sub-app *is* route + handler)
  and no mandated repository stack beyond `db/`.
- **`db/` is the tenant-isolation chokepoint.** One shared D1 database, a
  `tenant_id` column on every tenant-owned table, and every query issued
  through a per-request scoped accessor whose tenant key is closed over from
  a **verified credential** — never passed as a parameter below the
  middleware that constructs it. In code the accessor is named `repo`, not
  `db`: it wraps the one shared database; nothing here is
  database-per-tenant.
- **Scope accessors are constructed only in `routes/middleware.ts`** — the
  one file outside `db/` that may touch the D1 binding. Each route family is
  injected with the scope it is entitled to (global / tenant / staff);
  cross-tenant access goes only through the distinctly-named
  `forTenantAsStaff()`. Wiring, code, and the scope taxonomy:
  [tenant scoping guide](/guides/tenant-scoping/).
- A genuinely single-tenant product may relax `db/` to a growth path (inline
  SQL in services until queries crowd out logic); workspace-scoped is the
  default on this stack.
- **`Env` is generated, never hand-maintained.** `wrangler types` emits
  `worker-configuration.d.ts` from `wrangler.jsonc` + `.dev.vars`, run first
  by `npm run check` (§8). Nothing reads an untyped `env.SOMETHING`.

> Behavioral conventions (error mapping, module-size limits, auth shape) are a
> later phase — see §10–§12.

---

## 5. The shared boundaries — three scopes, three homes

| Home | Holds | SPA | Worker | Marketing |
|------|-------|:---:|:---:|:---:|
| **`config/`** (top level) | typed product facts | ✅ | ✅ | ✅ |
| **`styles/`** (top level) | design tokens (CSS) | ✅ | — | ✅ |
| **`src/shared/`** | app-internal type contract | ✅ | ✅ | — |

### `config/` — product facts (all three surfaces)

"Facts about the product a customer could see," in the one place all three
surfaces can read: `brand.ts` (name, origin, support address) and `routes.ts`
(app route paths + `isAppPath()`, pinned by `test/routes.test.ts`).

- Everything in `config/` is **pure data + types with zero runtime imports**.
- It resolves via a real Node subpath import —
  `"imports": { "#config/*": "./config/*.ts" }` in root `package.json` —
  mirrored in all three tsconfigs and `vite.config.ts` (§9).
- **Nothing enters `config/` until it has two real consumers.** One consumer
  is a local constant. (Per-tier limits read by both the pricing page and the
  Worker are the canonical future case — add them when tiers exist.)

### `styles/` — design tokens (SPA + marketing)

`tokens.css` is the visual contract, structured in layers, and the ordering is
the rule:

1. **Brand palette** — the raw scale, and the **only place a hex literal
   appears**.
2. **shadcn base** — the ~12 semantic roles (`--color-background`,
   `--color-primary`, `--color-muted`, …) mapped onto the palette via
   `var()`, never a re-typed hex. **Role names are fixed across every
   product; only the values differ** — that's what lets components port.
3. **Brand extensions** — extra roles a product needs (e.g.
   `--color-success`), added here on the same palette — never a parallel
   system.

**Dark mode lives in the base from day one** — a `.dark {}` block overriding
role assignments (not the palette). If the site uses a vendored/themed
template with its own token system, decide explicitly: remap its tokens onto
yours (`--nb-bg: var(--color-background)`) or accept two systems and document
the boundary. Silently having both is the option that rots.

### `src/shared/` — the app's internal contract (SPA + Worker)

Request/response shapes, shared enums, and pure domain helpers both app halves
use. No runtime dependencies; the only directory compiled by both app
tsconfigs. Marketing never imports it. The line against `config/`: this
describes the **API/domain**, not customer-facing product facts.

### Graduating to a workspace package

Top-level dirs resolved via aliases are right for one product. When a second
product needs to share either across repos, promote them to workspace packages
(`@product/config`, `@product/tokens`) — contents unchanged, only the
declaration moves.

---

## 6. Surface C — the marketing site (`marketing/`)

A **separate Astro project**, its own npm workspace, building all static
non-app pages: landing, pricing, legal, blog, docs. Isolated so its dependency
churn can't destabilize the app.

```
marketing/
├── astro.config.ts           # static output, docs integration,
│                             #   trailingSlash, prefetch, built-in i18n
├── package.json              # its own deps (workspace member)
├── tsconfig.json             # own ~/* alias + the shared #config/* alias
├── public/                   # second asset root — root namespace (see §2)
├── src/
│   ├── pages/                #   routes + generated endpoints (og, llms.txt,
│   │                         #   robots, sitemap); en/ locale variants
│   ├── content/              #   content collections (blog/, docs/)
│   ├── layouts/              #   page shells (Marketing vs Docs)
│   ├── components/           #   Astro components (+ a ui/ kit)
│   ├── lib/                  #   i18n dictionary + helpers
│   └── styles/               #   imports ../../../styles/tokens.css + site CSS
└── AGENT.md                  # authoring rules for this sub-project
```

**Rules**

- **Static output.** No SSR — every page is a file at build time.
- **Bilingual routing uses Astro's built-in i18n** (`prefixDefaultLocale:
  false`): unprefixed default locale, secondary under `/en/*`,
  `getRelativeLocaleUrl()` for URL generation. The framework does *not* write
  hreflang: pages that don't exist in both locales must pass their real
  alternates or they emit hreflang links to 404s.
- **Trailing slashes are load-bearing.** Directory-format output serves pages
  at `/pricing/`; `trailingSlash: "always"` plus slash-normalizing URL
  helpers keep canonical, hreflang, sitemap, and internal links on one form.
- **Shared *values* go in `config/`; shared *prose* does not.** The SPA and
  marketing keep separate i18n dictionaries by design — marketing copy and
  product copy diverge.
- `AGENT.md` documents authoring rules and must stay honest about the real
  deploy shape: this workspace merges into the app's asset output; it does
  not deploy standalone.

---

## 7. Serving model — how one Worker serves three surfaces

**Build produces one asset directory**: the marketing site's static pages
(owning `index.html` at `/`), the SPA bundle (shell renamed to `app.html`),
and all hashed assets.

**At runtime, requests resolve in this order:**

1. `run_worker_first: ["/api/*"]` sends API paths straight to the Worker.
2. Everything else hits the **static asset pipeline first** — real files are
   served with no Worker invocation.
3. **Unmatched paths fall through to the Worker's catch-all.** There is
   deliberately **no `not_found_handling`** in `wrangler.jsonc` — that's what
   makes misses invoke the Worker instead of blindly serving a SPA shell.
4. The catch-all calls `isAppPath()` from `config/routes.ts` — the same
   module the SPA and marketing read. Match → serve `app.html` with **200**.
   No match → serve the marketing **`404.html` with 404**.

Why `app.html`: marketing owns `index.html` at the root, so the SPA shell
builds under a different name (`vite.config.ts` sets
`build.rollupOptions.input` to `app.html`) and the Worker hands it out only
for real app routes.

The payoff: garbage URLs get a real 404, marketing owns `/`, and the
catch-all is the hook point for future per-route SSR touches. **Verify by
hand after any change to the serving model** — expected statuses and steps in
the [bootstrap guide](/guides/bootstrap/).

---

## 8. Build & deploy pipeline

Driven entirely by root `package.json` scripts. Order matters.

```
npm run build
  1. vite build                          → SPA bundle + Worker, into dist/client
  2. npm run build -w marketing          → Astro static site, into marketing/dist
  3. node scripts/merge-marketing.mjs    → copies marketing/dist into dist/client;
                                       HARD-FAILS on any filename collision
npm run deploy  = build + wrangler deploy
```

- **`npm run check`** — `wrangler types`, then type-checks all three projects.
- **`npm run dev`** — the app (Vite + Worker via `@cloudflare/vite-plugin`);
  **`dev:marketing`** — Astro alone; **`preview`** (after a build) — all three
  together, exactly as production serves them.
- **`test/`** — vitest + `@cloudflare/vitest-pool-workers`: worker code runs
  in workerd against isolated real bindings; `#config/*` resolves through
  `vite.config.ts`.
- **`migrations/`** — numbered SQL applied with `wrangler d1 migrations apply`
  (local + `--remote` scripts).

---

## 9. Config files — what each one owns

| File | Owns |
|------|------|
| `wrangler.jsonc` | Worker name, compat date, **bindings**, asset config (`run_worker_first`, no `not_found_handling`), non-secret `vars`. |
| `vite.config.ts` | SPA build + Worker bundling, the `app.html` input rename, bundle-time aliases (`@/*` → `src/client`, `#config` → `config/`). |
| `tsconfig.json` | **Client** project: DOM libs, `jsx`, `@/*`, `#config/*`; includes `src/client`, `src/shared`, `config`. |
| `tsconfig.worker.json` | **Worker** project: generated runtime types, no DOM, `#config/*`; includes `src/worker`, `src/shared`, `config`. |
| `package.json` | Root deps + scripts; the `marketing` workspace; the canonical `#config/*` subpath import (`"imports"`). |
| `marketing/tsconfig.json` | `~/*` → `marketing/src/*` plus the shared `#config/*` → `../config/*`. |
| `components.json` | shadcn generator config for the SPA primitives. |

**Alias rules:**

- **`@/*` belongs to the app alone** (`src/client/*`) — shadcn-generated code
  hard-imports it. Marketing uses **`~/*`** (`marketing/src/*`). The two
  projects never share a sigil.
- **`#config/*` is a real Node subpath import**, declared canonically in root
  `package.json` `"imports"` with an explicit `.ts` (Node does no extension
  guessing), mirrored in the three tsconfigs (typecheck) and
  `vite.config.ts` `resolve.alias` (bundles). All declarations are exercised
  by `npm run check` + `npm run build`.

---

## 10. Auth, sessions, and tenant isolation — later phase

> **Not standardized yet** beyond the structural rules in §4. Two security
> invariants hold wherever tenancy applies: **the tenant key comes from a
> verified credential** — the session for app routes, the verified
> signature/token for webhooks — **never from client input**; and it enters
> queries only through the `db/` scoped accessor constructed in scope
> middleware. Cross-tenant access goes only through `forTenantAsStaff()`.
> See the [tenant scoping guide](/guides/tenant-scoping/).

---

## 11. Testing contract — later phase

> **Not standardized yet.** The fixed part is structural: tests live in
> `test/` and run with vitest + `@cloudflare/vitest-pool-workers` (§8). What
> must be tested, and to what bar, settles with the backend conventions.

---

## 12. Environment, secrets, and validation — later phase

> **Mostly not standardized yet.** The structural part: public constants in
> `config/` (§5), per-environment values in `wrangler.jsonc` `vars`, secrets
> via `wrangler secret put` / `.dev.vars` (§9). One hard rule: **never put a
> secret in `vars`** — a plaintext `var` overwrites the real secret with `""`
> on the next deploy.

---

## 13. Conformance

**Invariants — this list is the conformance test:**

- One deploy, one origin — never split the API onto a second service.
- Shared things live by consumer: `config/` (all three surfaces), `styles/`
  (app + marketing), `src/shared/` (app-internal only). No surface redefines
  another's shapes.
- Nothing enters `config/` until it has **two real consumers**.
- The Worker splits HTTP (`routes/`) from logic (`services/`) from data
  access (`db/`).
- The D1 binding is touched only under `db/` and `routes/middleware.ts`;
  services take the scoped accessor injected by their route family's
  middleware; its tenant key comes from a verified credential (§4, §10).
- `Env` comes from `wrangler types`, never a hand-maintained interface.
- `api.ts` is the sole client↔API boundary.
- App route paths are declared once in `config/routes.ts` — never
  hand-mirrored.
- The marketing build merges into the app's assets with a collision guard;
  marketing owns `/`; the SPA shell is `app.html`; app `public/` stays under
  its reserved prefix.
- `npm run check` and `npm test` pass.

**Mechanizable checks** (run in the reference repo; part of the template when
it exists):

| Invariant | Check |
|---|---|
| Shared things live by consumer | import-boundary lint: `marketing/**` never imports `src/shared`; `src/worker/**` never imports DOM globals |
| `config/` two-consumer rule | count importing files per export across surfaces; fail under 2 |
| `api.ts` sole boundary | grep for `fetch(` under `src/client` outside `api.ts` |
| Route paths declared once | grep for hardcoded app-path literals outside `config/routes.ts` |
| Data access only via `db/` | grep for the D1 binding (`env.DB`, `.prepare(`) outside `src/worker/db/` + `routes/middleware.ts` |
| Serving model (§7) | scripted status assertions — table in the [bootstrap guide](/guides/bootstrap/) |

What changes per product: the domain and `wrangler` name, the `Env` bindings
and DB name, the route modules and service/db domains, the `config/` values
and token palette, and the marketing content. The *shape* stays identical.
The step-by-step skeleton sequence: [bootstrap guide](/guides/bootstrap/).

---

## 14. Versioning

This document is the single source of truth, versioned here (`Standard
version: 1.x` at top). A product repo records which version it conforms to (a
line in its README or a `standard-version` field) and pins it. When the
standard changes in a way existing products should adopt, the changelog entry
says *what* and *whether it's retroactive*; conforming products bump their pin
deliberately.

| | Where it lives | Why |
|---|---|---|
| This standard | **here, canonical** | products reference a version, never fork the prose |
| Skeleton (layout, configs, serving model) | **template repo** (future) | copied once at birth |
| `styles/tokens.css` | **copy the contract, not the values** | role names identical; palette per-brand |
| `config/*` | **copy** | values product-specific; only the shape is shared |

**Reference implementation status.** The production repo this standard was
extracted from predates several v1.4–1.5 conventions (`components/ui`,
`services/`, the `db/` chokepoint, generated `Env`, vitest, `package.json`
imports, `~/*`, built-in i18n). Migrating it is the open conformance task.

**Changelog**

- **1.6** — Restructured to normative-only: implementation how-tos moved to
  the guides; rationale essays and roadmap material removed. No rule changes.
- **1.5** — `db/` required by default as the tenant-isolation chokepoint:
  per-request scoped accessors constructed in scope middleware
  (global/tenant/staff taxonomy, `forTenantAsStaff()` for sanctioned
  cross-tenant access). Single-tenant products may relax to a growth path.
- **1.4** — Realigned onto ecosystem defaults: `components/ui/`, worker
  `services/`, generated `Env` via `wrangler types`, `#config/*` as a real
  subpath import, `~/*` for marketing, vitest workers pool, app `public/`
  prefix rule, Astro built-in i18n.
- **1.3** — Extracted to a canonical single-source standard; examples
  genericized; base-token contract added to §5.
- **1.2** — Scope narrowed to repository structure; worker internals became
  later-phase stubs (§10–§12).
- **1.1** — Split cross-surface sharing into `config/` + `styles/`; route
  paths moved to `config/routes.ts`.
- **1.0** — Initial: one-Worker three-surface layout, serving model, build
  pipeline.

Source: https://docs.michaelchen.me/standards/architecture/index.mdx
