Status: accepted, amended 2026-10-30 (locales) · Date: 2026-10-17 · Related: ADR-0002, ADR-0011
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); the web should read the same way, with the same feature names where a backend feature exists.
Decision
Three layers, one direction: routes → features → platform.
apps/web/src/routes/stays file-based routing (TanStack Router) and becomes thin:validateSearch, guards inbeforeLoad, aloaderthat callsprefetch*,head, and a small wrapper that passessearch/navigateto the feature's page as props. Server routes delegate to a.servermodule.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 isassets/, for generated JSON and SVG (the generators writefeatures/landing/assets/worker.jsonandfeatures/auth/assets/geometry.jsonunder 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 @wundklare/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-30: the catalog is one immutable asset, not part of the 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.tsholds every catalog (the locale files plus the error-code messages ofpackages/errors) and initialises the shared i18next instance;server.tsimports it first, so SSR,getRequestI18n, the OG and agent documents keep all three languages. Tests import it instead ofintl.tsto translate against the full catalogs.platform/i18n/intl.tskeeps the typed keys throughimport typeonly.- The root route's
beforeLoadstill pickslang(the path on a public page, the cookie elsewhere, seepackages/intl/src/server.ts); itsloaderreturns{ catalogVersion }only. Itshead()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-originfetch(); without it the browser fetches the catalog twice. apps/web/src/client.tsxis 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 callsinitClientIntl(lang, catalog, version). The first client render therefore translates with the same catalog as SSR: no hydration mismatch, no waterfall (the catalog, 20 to 22 KiB gzip, arrives before the 106 KiB entry has).initClientIntlonly refreshes an existing locale cookie, as the module-scope code did before: a shared/delink must not turn/into a redirect for a visitor who never chose German. When the page carries no link or the fetch fails,loadBootCatalogresolves false and hydration goes ahead;RootComponentthen callsrecoverMissingCatalog(lang)in an effect, which fetches the catalog and re-renders in place, again without writing the cookie. That degraded path is not free: the first client render emits raw keys against the translated SSR HTML (the JSON-LD in<head>included), so React 19 reports a recoverable hydration error and re-renders the tree on the client before the recovery repaints it; the page stays usable, and the path only runs when the catalog request itself failed.langin the root context isrootLanguage(getSSRLanguage()): the request's language on the server, and in the browser, where the isomorphic function is undefined and the router re-runs everybeforeLoadon each client-side navigation, the hydrated language (i18n.resolvedLanguage). Without the fallback one<Link>click after a German render would makelangundefined, the recovery effect would fall back to English and fetchen.json, and the preload inhead()would name the wrong catalog (found in review of the first version of this PR).- On a public page the language switcher stays a list of links (one URL per language, #162), so the server renders the other language and preloads its catalog. Under the app shell it 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 withaddResourceBundle, thenchangeLanguagepersists the choice; noimport(). versionis a content hash of every catalog (catalogVersionOf,packages/intl), computed whencatalogs.server.tsinitialises the server instance and handed to the browser in the preload link and the root loader data. A request with the current version is answeredpublic, max-age=31536000, immutable; any otherv(or none) gets the current catalog withno-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.StructuredDatain the root document translates through the render's i18next instance (getFixedT) instead of indexing the fullresourcesobject, which the browser no longer has.
Measured on the Cloudflare build at e321937 against this change (workerd, wrangler dev --local, n=30, TTFB p50; measurement): the client entry went from 529.8 kB (168.9 kB gzip) to 331.4 kB (105.7 kB gzip), −37 % gzip; all client JS from 870.1 to 807.0 kB gzip. The HTML is unchanged within 0.2 kB (/ 263.7 → 263.9 kB, /docs 66.2 → 66.4 kB: the preload link and catalogVersion in the dehydrated root match). A first visit pays one extra parallel request for the catalog (en 63.7 kB raw, 20.3 kB gzip); a repeat visit pays nothing for it (immutable). TTFB was unaffected within noise. In Chrome against the built Worker, /de hydrates without a mismatch with exactly one catalog request, initiated by the preload link; switching to French under the app shell fetches /locales/fr.json?v=… once and re-renders the chrome in place; a reload then server-renders French from the cookie and preloads the French catalog.
A first version of this amendment (PR #145, 2026-10-21) put the catalog in the root loader data instead; that made every HTML response carry it (about 64 KiB raw, 13 KiB gzip, never cached) and was replaced by the preload above.
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.serversuffix 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.jsonoverrides. 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
- The move renamed 355 files into
features/andplatform/; splitting long routes and components removed 3 files and added 23 (431 → 451 files inapps/web/src). Routes, locales and the root files kept their 73 paths. Docs, skills, the removal scripts,system-call-sites.txtand the diagram generators followed the new paths. Dated plans and measurements keep their original paths. URLs,routeTree.gen.tsand 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
*.queriesinstead of the hooks; the server code grew 850 B gzip; no Effect module reaches the client (measurement). - 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-featureskill. apps/mobile/src/lib/user-agent.tsstill 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.