# Web features mirror the backend shape

The repository's architectural decision, rationale and consequences.

# Web features mirror the backend shape

**Status:** accepted, amended 2026-10-21 (locales) · **Date:** 2026-10-17 · **Related:** [ADR-0002](0002-no-barrels-on-server-capable-modules.md), [ADR-0011](0011-backend-feature-shape.md)

**Enforcement:** `scripts/web-feature-boundaries.test.mjs` (W1–W11: layers, cross-feature edges, server suffix, no `createServerFn`, data access in hooks, no `.client.`, no new dynamic `import()`, no barrel, resolvable test specifiers, flat feature folders, and no import cycles through oxlint's `import/no-cycle`), the client boundary and `max-lines` warnings in `apps/web/src/.oxlintrc.json`, TanStack Start import protection in the build, and `apps/web/scripts/assert-client-bundle-clean.mjs` and `assert-server-fn-manifest.mjs` in `pnpm --filter web build`.

`apps/web/src` was organized by technical kind: `components/`, `hooks/`, `lib/`, `utils/`, `functions/`, `middleware/`, `og/` and `data/`. One screen was spread across four of those folders, and server-only modules sat next to client code with nothing in their name to say so. The backend now has one folder per feature with fixed role names ([ADR-0011](0011-backend-feature-shape.md)); the web should read the same way, with the same feature names where a backend feature exists.

## Decision [#decision]

**Three layers, one direction: `routes` → `features` → `platform`.**

- `apps/web/src/routes/` stays file-based routing (TanStack Router) and becomes thin: `validateSearch`, guards in `beforeLoad`, a `loader` that calls `prefetch*`, `head`, and a small wrapper that passes `search`/`navigate` to the feature's page as props. Server routes delegate to a `.server` module.
- `apps/web/src/features/<feature>/` holds one feature per folder, named like its backend pair when there is one (`todo`, `documents`, `billing`, …). Files are named `<feature>.<role>.ts(x)` or `<feature>.<topic>.<role>.ts(x)`. The folder is flat; the only sub-folder is `assets/`, for generated JSON and SVG (the generators write `features/landing/assets/worker.json` and `features/auth/assets/geometry.json` under their old file names).
- `apps/web/src/platform/<area>/` holds what has no domain: the oRPC client, session, i18n, errors, theme, layout, data table, SEO, OG, consent. It imports neither features nor routes.

**Fixed role names.** `<f>.queries.ts` (query and mutation options, keys, `prefetch*` for loaders), `<f>.hooks.ts` (state, effects, mutations, the auth client), `<f>.page.tsx` (the screen, given `search`/`navigate` by its route), components `<f>.<name>.tsx` (presentation only), pure model files `<f>.<topic>.ts`, and server-only `<f>[.<topic>].server.ts(x)`. Search params are validated in the route, usually with the schema the backend contract exports. Queries and hooks are the public surface of a feature, with any other role the boundary test lists as public; another feature imports its components or model only through an edge declared in the boundary test, with its reason. Components do not import TanStack Query, the oRPC client or the auth client: that logic lives in hooks. The web has no contract of its own: it imports `@template-cloudflare/api/<feature>/contract`.

**`.server` is the server-only marker, because the framework enforces it.** The installed `@tanstack/start-plugin-core` (1.171.52) denies `**/*.server.*` files to the client environment by default (and `**/*.client.*` to the server). Its default behaviour is `error` in `vite build` and `mock` in `vite dev`: a denied import still in the client graph after the compiler strips `.server()` branches fails the build, while the dev server replaces it with a mock that logs an error when used. A probe confirmed it on the Cloudflare build before the move: a `probe.server.ts` rendered by a client component failed `pnpm --filter web build` with `[import-protection] Import denied in client environment`. Every server-only module outside `routes/` and `server.ts` carries the suffix. Client code may reach a `.server` module only through a closed list of two isomorphic bridges that use it inside `createIsomorphicFn().server()`: the session query (`platform/auth/session-query.ts`) and the docs page loader (`features/docs/docs.page-loader.ts`). Server routes, `server.ts`, tests and the handler-only resource routes (`docs[.]md.ts`, `llms[.]txt.ts`, `sitemap[.]xml.tsx`, …) import `.server` modules directly. The oRPC client (`platform/rpc/orpc.ts`) stays the exception ADR-0002 names: it imports no `.server` file but value-imports the backend router inside its own `.server()` branch, and the web lint config carves it out of the client boundary by path.

**No `createServerFn`.** A server function reached only through a `.server()` branch shipped in the Worker with an empty manifest, and `/_auth` answered 500 (`070873c`). The web keeps zero server functions; adding one means changing this record, the boundary test and passing `assert-server-fn-manifest.mjs`.

**No `.client.` in any file name.** The import protection denies `**/*.client.*` on the server, so a client-named module imported during SSR would break rendering. The auth client keeps its hyphen (`auth-client.ts`).

**No barrel and no generic folder.** Files import each other directly. A component used by several features stays with the feature that owns it and is imported through a declared edge; if it has no domain, it moves to `platform/`.

**Locales stay central.** `apps/web/src/locales/{en,de,fr}.ts` are not split by feature: the parity tests compare whole catalogs, and the removal scripts edit them by path. A new key uses its feature's prefix (`TODO_`, `DOCS_`, …). The original text also accepted every locale in the client entry because `import()` is banned; the amendment below removes them from the entry without one.

**Direction is checked by a test, not by lint.** In the installed oxlint, the options of `no-restricted-imports` in an `override` replace those inherited through `extends`; they do not merge. A direction rule on `features/**` would silently drop the client/server boundary of ADR-0002 from every file it matches, unless each override repeated the whole boundary list. The boundary stays in lint, unchanged; layers, cross-feature edges and the server suffix are checked by `scripts/web-feature-boundaries.test.mjs`. Import cycles are a test as well, the W11 case of the same file, which runs oxlint's `import/no-cycle` on `apps/web/src` with the web `tsconfig` inside `pnpm test`, as D7 does for the server trees. In a local measurement the rule in the web lint config made `pnpm lint` about 55 % slower, over the 30 % the backend set as its limit (ADR-0011); a second measurement under heavy machine load was inconclusive, so the backend precedent decides. File size stays in lint as a warning (`max-lines` 250 in `features/` and `platform/`, 100 in `routes/`).

## Amendment 2026-10-21: one locale in the client entry [#amendment-2026-10-21-one-locale-in-the-client-entry]

The client entry carried the three catalogs (about 212 KiB of source, 60 KiB gzip) because `platform/i18n/intl.ts` imported `en`, `de` and `fr` to initialise i18next at module scope, and a per-locale `import()` would break W7. The browser now starts with the one catalog the server rendered with, fetched as its own immutable asset, and neither the entry nor the HTML carries a catalog:

- `platform/i18n/catalogs.server.ts` holds every catalog and initialises the shared i18next instance; `server.ts` imports it first, so SSR, `getRequestI18n` and the OG and agent documents keep all three languages. Tests import it instead of `intl.ts` to translate against the full catalogs.
- The root route's `beforeLoad` still picks `lang`; its `loader` returns `{ catalogVersion }` only. Its `head()` adds `<link rel="preload" as="fetch" crossorigin="anonymous" href="/locales/<lang>.json?v=<version>" data-i18n-catalog="<lang>">` (`catalogPreloadLink`, `platform/i18n/boot-catalog.ts`), so the browser's preload scanner starts the catalog request while the HTML is still parsing, in parallel with the entry script. `crossorigin="anonymous"` is what makes the preload match a plain same-origin `fetch()`; without it the browser fetches the catalog twice.
- `apps/web/src/client.tsx` is TanStack Start's default client entry (`hydrateRoot(document, <StartClient />)`) with one await in front: `loadBootCatalog()` reads the link, fetches the catalog (served from the preload cache) and calls `initClientIntl(lang, catalog, version)`, which also writes the locale cookie `getSSRLanguage` reads. The first client render therefore translates with the same catalog as SSR; no hydration mismatch, no waterfall (the catalog, about 21 KiB gzip, arrives before the 126 KiB entry has). When the page carries no link or the fetch fails, `loadBootCatalog` resolves false and hydration goes ahead; `RootComponent` then calls `recoverMissingCatalog(lang)` in an effect, which fetches the catalog and re-renders.
- A language switch calls `switchLanguage` (`platform/i18n/intl.ts`): a catalog the browser does not have is fetched once from the handler route `/locales/{$lang}.json?v=<version>` and added with `addResourceBundle`; no `import()`. `version` is a content hash of every catalog (`catalogVersionOf`, `packages/intl`), computed when `catalogs.server.ts` initialises the server instance and handed to the browser in the preload link and the root loader data. A request with the current version is answered `public, max-age=31536000, immutable`; any other `v` (or none) gets the current catalog with `no-cache`. A deploy that changes a key changes the URL, so no cache, shared or private, can hand a long-lived tab a catalog without the key.
- Typed keys are unchanged: `MessageKey` and the `CustomTypeOptions` declarations use `import type en`, which the compiler erases.

Measured on the Cloudflare build (workerd, `wrangler dev --local`, n=30, TTFB p50 / HTML bytes): the client entry went from 537.8 kB (172.2 kB gzip) to 395.3 kB (126.1 kB gzip). A first version of this amendment put the catalog in the root loader data instead; that made every HTML response carry it (`/` 268 → 331 KB, `/docs` 65 → 129 KB, `/dashboard` 56 → 120 KB, about 13 KiB gzip each, never cached) and was replaced by the preload above, which brought the HTML back to 268 / 65 / 56 KB while a repeat visit pays 0 bytes for the catalog (immutable) and a first visit pays the same bytes as before in one extra parallel request. TTFB was unaffected either way: `/` 10.3 → 9.5 ms, `/docs` 5.2 → 4.4 ms, `/dashboard` 19.8 → 18.7 ms (within noise); the dehydrated catalog streamed after the headers. Per-stage timings on `/dashboard` showed its +3 ms against the pre-refactor baseline comes from `audit.myActivity` (5 → 7 ms on the seed-sized table), the UNION ALL keyset rewrite of `docs/measurements/2026-10-21-indexes.md` that trades a few ms of planning on a small table for 11.9 → 5.7 ms on a 50 000-row one; session, shell prefetch and the other cards are unchanged.

The same change gives public HTML a shared-cache policy: `/`, `/docs/*`, `/privacy` and `/terms` answer `Cache-Control: public, s-maxage=300, stale-while-revalidate=86400` through the route `headers` option (`platform/seo/public-cache.ts`) only when the request carries neither a Better Auth session cookie (`better-auth.session_token`, `__Secure-` prefixed over https) nor the locale cookie; any other request gets `private, no-store`. The landing and legal pages read `sessionCookie` from the root context and skip `get-session` for anonymous visitors.

Two conditions keep that policy safe:

- **Errors are never public.** TanStack Router evaluates the `headers` option of every match up to and including the one that failed, so a `/docs/*` 404 or a loader error on `/` would otherwise leave with the layout's `public` policy and be kept by the edge for five minutes. The Worker entry (`server.ts`) passes every response through `withoutPublicCacheOnErrors`: a 4xx/5xx whose `Cache-Control` says `public` leaves as `private, no-store`.
- **`Vary: Cookie` is required at the CDN.** Every public page sends it with its `Cache-Control`, and the policy relies on it: the same URL is rendered differently for a visitor with a session or locale cookie, and only the cookie distinguishes the two requests. A Cloudflare Cache Rule (or any "cache everything" rule) that ignores the origin's `Vary`, strips cookies from the cache key or overrides the origin `Cache-Control` with its own TTL would serve a signed-in render, or a 500, to anonymous visitors. Such rules must exclude this Worker's routes or respect the origin headers; the HTML cache is the origin's decision, never the CDN's.

## Why not the alternatives [#why-not-the-alternatives]

- **A `src/server/` folder for server code.** It groups server files away from the feature they serve, and the folder name protects nothing; the `.server` suffix is what the framework enforces.
- **A barrel per feature.** It is the shape that once carried server code into the browser (ADR-0002) and hides which file a screen actually uses.
- **A generic `components/` folder.** It is where ownership disappears; most of today's components belong to exactly one feature.
- **Direction rules in `.oxlintrc.json` overrides.** See above: they would switch off the client/server boundary in the files they cover.
- **Locales split per feature.** No bundle gain without dynamic imports, and a large, collision-prone churn. The amendment removes the catalogs from the entry by serving the request's one, which needs no split.
- **A per-locale `import()` for the catalog.** It would reopen W7 for one call site; a preloaded JSON route carries the same bytes through mechanisms the boundary checks already see.
- **The catalog in the root loader data.** Tried first (see the amendment): correct and simple, but it put about 13 KiB gzip of uncacheable catalog into every HTML response, where the old entry had been cached across visits.
- **A blocking `<script src="/locales/<lang>.js">` in `<head>` assigning a global.** It also orders itself before the entry, but a classic script after the stylesheet waits for the CSS and blocks the parser, so the body's streamed HTML and hydration scripts would wait for it on a first visit; the preload blocks nothing.

## Consequences [#consequences]

- The move renamed 355 files into `features/` and `platform/`; splitting long routes and components removed 3 files and added 23 (431 → 451 files in `apps/web/src`). Routes, locales and the root files kept their 73 paths. Docs, skills, the removal scripts, `system-call-sites.txt` and the diagram generators followed the new paths. Dated plans and measurements keep their original paths. URLs, `routeTree.gen.ts` and every route response stayed the same.
- Code: the client bundle grew 425 B gzip in total and the entry chunk shrank 1,128 B, because loaders now import `*.queries` instead of the hooks; the server code grew 850 B gzip; no Effect module reaches the client ([measurement](../measurements/2026-10-17-web-structure.md)).
- Docs content: the pages this change adds or edits (this record, the web conventions, the changelog entry and the updated guides) are compiled into the server's docs chunk and add about 23.6 KiB gzip to it. With them, the server grows by about 24.5 KiB gzip in total; the client is unchanged by the docs (independent review measurement, gzip -9 per file).
- New screens follow the web section of the `domain-feature` skill.
- `apps/mobile/src/lib/user-agent.ts` still cites the old web path in a comment until a mobile change updates it.
- Unused vendored components and files moved as they were; deleting them is a separate decision.

