Skip to content

Product Architecture Standard

One Worker, one deploy, one domain — the canonical repo structure every product on this stack starts from.

Updated View as Markdown

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

  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 tsconfigs 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.
  • 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.


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 checkwrangler 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, #configconfig/).
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.


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

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.5db/ 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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close