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 and bootstrapping a product.
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
-
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. -
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’ssrc/shared/. See §5. -
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.
-
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
tsconfigs that share onlysrc/shared. -
The Worker splits HTTP from logic from data access. Route files (
routes/) do HTTP; business logic lives inservices/as plain functions; data access lives indb/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.mdTwo 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.tsxcomposes the routes; the paths come fromconfig/routes.tsso the Worker’s shell-vs-404 decision (§7) and marketing’s CTAs can’t drift.api.tsis the only placefetchtalks to the API: onecall<T>()wrapper that sets credentials, parses JSON, and throws a typedApiFailure(status, code)on non-2xx. Request/response types come fromsrc/shared, never redefined.components/ui/keeps the shadcn generator default (components.jsonshipsui: "@/components/ui") — do not rename it. Edits to its files stay theme/behavior-level; feature composition lives in feature components. No siblingui.tsxnext to theui/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 inservices/, data access indb/. There is no separate handler layer (a Hono sub-app is route + handler) and no mandated repository stack beyonddb/. db/is the tenant-isolation chokepoint. One shared D1 database, atenant_idcolumn 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 namedrepo, notdb: it wraps the one shared database; nothing here is database-per-tenant.- Scope accessors are constructed only in
routes/middleware.ts— the one file outsidedb/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-namedforTenantAsStaff(). Wiring, code, and the scope taxonomy: tenant scoping guide. - 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. Envis generated, never hand-maintained.wrangler typesemitsworker-configuration.d.tsfromwrangler.jsonc+.dev.vars, run first bynpm run check(§8). Nothing reads an untypedenv.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 rootpackage.json— mirrored in all three tsconfigs andvite.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:
- Brand palette — the raw scale, and the only place a hex literal appears.
- shadcn base — the ~12 semantic roles (
--color-background,--color-primary,--color-muted, …) mapped onto the palette viavar(), never a re-typed hex. Role names are fixed across every product; only the values differ — that’s what lets components port. - 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-projectRules
- 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.mddocuments 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:
run_worker_first: ["/api/*"]sends API paths straight to the Worker.- Everything else hits the static asset pipeline first — real files are served with no Worker invocation.
- Unmatched paths fall through to the Worker’s catch-all. There is
deliberately no
not_found_handlinginwrangler.jsonc— that’s what makes misses invoke the Worker instead of blindly serving a SPA shell. - The catch-all calls
isAppPath()fromconfig/routes.ts— the same module the SPA and marketing read. Match → serveapp.htmlwith 200. No match → serve the marketing404.htmlwith 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.
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 deploynpm 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 throughvite.config.ts.migrations/— numbered SQL applied withwrangler d1 migrations apply(local +--remotescripts).
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 rootpackage.json"imports"with an explicit.ts(Node does no extension guessing), mirrored in the three tsconfigs (typecheck) andvite.config.tsresolve.alias(bundles). All declarations are exercised bynpm 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 throughforTenantAsStaff(). See the tenant scoping guide.
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 inwrangler.jsoncvars, secrets viawrangler secret put/.dev.vars(§9). One hard rule: never put a secret invars— a plaintextvaroverwrites 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/androutes/middleware.ts; services take the scoped accessor injected by their route family’s middleware; its tenant key comes from a verified credential (§4, §10). Envcomes fromwrangler types, never a hand-maintained interface.api.tsis 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 isapp.html; apppublic/stays under its reserved prefix. npm run checkandnpm testpass.
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 |
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.
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/, workerservices/, generatedEnvviawrangler types,#config/*as a real subpath import,~/*for marketing, vitest workers pool, apppublic/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 toconfig/routes.ts. - 1.0 — Initial: one-Worker three-surface layout, serving model, build pipeline.