Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Web features mirror the backend shape

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · 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 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: without dynamic imports every locale is in the bundle anyway, the parity tests compare whole catalogs, and the removal scripts edit them by path. A new key uses its feature's prefix (TODO_, DOCS_, …).

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/).

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.

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).
  • 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.
Edit on GitHubDocumentation is in English.