# Web conventions

The rules for code in apps/web/src, each with its reason, an example, the anti-pattern and what enforces it.

These rules apply to `apps/web/src`. They mirror the [backend conventions](/docs/development/backend-conventions) and are recorded in [ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape). Each rule has an ID (W1–W14) that the boundary test, the [enforcement matrix](/docs/development/enforcement) and reviews refer to. Enforcement levels are explained on the enforcement page.

## The shape [#the-shape]

```text
apps/web/src/
  routes/            file-based routing, thin; imports features and platform
  features/<f>/      one folder per feature: <f>.queries.ts, <f>.hooks.ts, <f>.page.tsx,
                     components <f>.<name>.tsx, model <f>.<topic>.ts,
                     server-only <f>[.<topic>].server.ts(x), assets/ (generated files only)
  platform/<area>/   no domain: rpc, auth, i18n, errors, theme, layout, data-table, seo, og, …
  locales/           en, de, fr (central)
```

| Role       | File                                                                                         | Public to other features                                             |
| ---------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Queries    | `<f>[.<topic>].queries.ts`: `queryOptions`, `mutationOptions`, keys, `prefetch*` for loaders | yes                                                                  |
| Hooks      | `<f>[.<topic>].hooks.ts`: state, effects, mutations, the auth client, `useQuery`             | yes                                                                  |
| Page       | `<f>.page.tsx` (and `<f>.page-view.tsx` when the page splits container and view)             | to `routes/` only                                                    |
| Components | `<f>.<name>.tsx`: presentation                                                               | no, unless declared                                                  |
| Model      | `<f>.<topic>.ts`: pure logic                                                                 | no, unless declared                                                  |
| Server     | `<f>[.<topic>].server.ts(x)`                                                                 | to server routes, `server.ts`, other `.server` files and the bridges |
| Tests      | `<f>.<topic>.test.ts(x)`, next to the file                                                   | —                                                                    |

Search params are validated in the route's `validateSearch`, usually with the schema the backend contract exports (`todoSearchInput`), and the route passes `search` and `navigate` to the page. Whether a feature-level module for search-param helpers is a public role is decided by `PUBLIC_ROLES` in the boundary test.

## W1. Imports flow routes → features → platform [#w1-imports-flow-routes--features--platform]

**Rule.** `platform/**` imports no `features/**`. No file imports a route file or `router.tsx`, except tests and the generated `routeTree.gen.ts`.

**Why.** Each layer can be read without the one above it, and a feature can be moved or deleted without touching platform code.

**Example.** [`routes/_auth/todos.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/_auth/todos.tsx) imports `@/features/todo/todo.page`, which reads its data through `@/features/todo/todo.hooks`, which imports `@/platform/rpc/orpc`.

**Anti-pattern.** A platform helper that imports a feature's hook "because it is convenient", or a page that imports its own route to read `Route.useSearch()`: the route passes `search` and `navigate` to the page as props instead.

**Allowed, with care.** `getRouteApi("/_auth/admin/audit")` names a route by its ID without importing it, so W1 does not see it. It couples the component to that URL; use it only inside the feature that owns the route.

**Enforcement.** `[test]` `scripts/web-feature-boundaries.test.mjs`.

## W2. Features meet through public roles [#w2-features-meet-through-public-roles]

**Rule.** A feature imports another feature only through its public roles (`*.queries`, `*.hooks` and any other role listed in `PUBLIC_ROLES` of the boundary test), or through an edge declared in the boundary test with its reason. Imports between features use `@/features/<g>/…`, never `../`. `import type` is free. A route may import any non-test file of any feature: it is the composition root.

**Why.** The public roles are the stable surface. A shared component is a deliberate, visible dependency instead of an accident.

**Example.** `settings.page-view.tsx` shows the activity timeline of `audit` through the declared edge `audit.activity-timeline.tsx` ← `settings`.

**Anti-pattern.** `import { TodoGrid } from "../todo/todo.grid"` from another feature.

**Enforcement.** `[test]` `scripts/web-feature-boundaries.test.mjs`: an undeclared edge fails, a declared edge that no longer exists fails, and so does a relative import across features.

## W3. Server-only code is named `.server` [#w3-server-only-code-is-named-server]

**Rule.** Every server-only module outside `routes/` and `server.ts` ends in `.server.ts(x)`. A file that is not `.server` imports a `.server` file only if it is a server surface or one of the two isomorphic bridges. The server surfaces are `server.ts`, tests and `testing/**` (test support imported by tests only), `routes/api/**`, `routes/[.]well-known/**` and the handler-only resource routes: a `[.]` in the name and no `component` (`docs[.]md.ts`, `llms[.]txt.ts`, `sitemap[.]xml.tsx`, …). A route with a component renders in the browser and is not a server surface, whatever its name. The bridges are [`platform/auth/session-query.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/auth/session-query.ts) and [`features/docs/docs.page-loader.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/docs/docs.page-loader.ts); each uses the `.server` module only inside `createIsomorphicFn().server()`.

The oRPC client, [`platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/rpc/orpc.ts), is not on that list: it imports no `.server` file. It value-imports the backend router inside its own `.server()` branch, the exception ADR-0002 names, and is carved out of the client boundary lint by path (W13).

**Why.** TanStack Start's import protection denies `**/*.server.*` files to the client environment. In `vite build` a denied import that is still in the client graph after the compiler strips `.server()` branches fails the build; in `vite dev` it becomes a mock that logs an error when used. The suffix turns a naming convention into a build error.

**Example.** `platform/auth/read-session.server.ts` reads the session for server routes; the client asks through the session query bridge.

**Anti-pattern.** A component that imports `features/files/files.gateway.server.ts` to reuse a constant. Put the constant in a leaf instead.

**Enforcement.** `[test]` boundary test, which also fails on a listed bridge that no longer imports a `.server` file · `[build]` import protection · `[build]` `assert-client-bundle-clean.mjs`, with markers that exist only in web server modules.

## W4. No `createServerFn` [#w4-no-createserverfn]

**Rule.** `apps/web/src` contains no `createServerFn`. Adding one changes the boundary test and ADR-0012, and must pass `assert-server-fn-manifest.mjs`.

**Why.** A server function reached only through a `.server()` branch shipped with an empty manifest, and `/_auth` answered 500 (`070873c`).

**Anti-pattern.** Wrapping a session read in `createServerFn` to call it from a component.

**Enforcement.** `[test]` boundary test · `[build]` `apps/web/scripts/assert-server-fn-manifest.mjs`.

## W5. Components present; hooks hold the logic [#w5-components-present-hooks-hold-the-logic]

**Rule.** Components (`features/**/*.tsx` and `routes/**/*.tsx`, except tests) make no value import of `@tanstack/react-query`, `files-sdk/react`, `@/platform/rpc/orpc` or `@/platform/auth/auth-client`.

**Why.** Data access, cache keys and mutations live in one place per feature, and a component can be rendered with fake data.

**Example.** [`todo.page.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.page.tsx) reads its data with `useTodos()` from [`todo.hooks.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.hooks.ts) and passes it to `todo.board.tsx` as props; the route's loader prefetches through [`todo.queries.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.queries.ts).

**Anti-pattern.** `useQuery(orpc.todo.list.queryOptions(…))` inside a component, or a `useFiles()` handle created in a page and drilled into every card. The rule is about the module, not the import text: reaching the oRPC client through a relative path, or TanStack Query through a re-export in another file, breaks it the same way.

**Enforcement.** `[test]` boundary test. Its transition list is empty: no component is exempt, and an entry added later fails once the file is fixed.

## W6. No `.client.` in a file name [#w6-no-client-in-a-file-name]

**Rule.** No file in `apps/web/src` has `.client.` in its name.

**Why.** The import protection denies `**/*.client.*` on the server; a module with that name imported during SSR would break rendering. That is why the auth client is `auth-client.ts`.

**Enforcement.** `[test]` boundary test.

## W7. No new dynamic `import()` [#w7-no-new-dynamic-import]

**Rule.** Production code has no dynamic `import()` beyond the allowlist: the native/WASM choice in the OG renderer and the development-only devtools in the root document.

**Why.** Code splitting is the framework's job (it splits route components from loaders); an ad hoc `import()` hides a module from the static graph the boundary checks rely on.

**Enforcement.** `[test]` boundary test.

## W8. No barrel [#w8-no-barrel]

**Rule.** No `index.ts(x)` outside `routes/` (where an index file is a route) and no `export *`, with or without `as` (`export * as ns from`), in `apps/web/src`.

**Why.** Direct imports show which file a screen uses; a barrel is how server code reached the browser before ([ADR-0002](/docs/adr/0002-no-barrels-on-server-capable-modules)).

**Enforcement.** `[test]` boundary test.

## W9. Test-only specifiers resolve [#w9-test-only-specifiers-resolve]

**Rule.** Every `@/…` or relative specifier in `vi.mock(...)`, `vi.importActual(...)`, a test's `import()` and `new URL("./…", import.meta.url)` points at an existing file.

**Why.** The type checker does not read those strings. A stale `vi.mock` path mocks nothing and the test passes for the wrong reason; a stale worker URL fails only at runtime.

**Example.** The Document Text conversion worker is created with `new URL("./documents.worker.ts", import.meta.url)`.

**Enforcement.** `[test]` boundary test.

## W10. Feature folders are flat and prefixed [#w10-feature-folders-are-flat-and-prefixed]

**Rule.** `features/<f>/` holds only files named `<f>.…`, plus `assets/` with generated `.json` and `.svg` files.

**Why.** The file name says which feature and role it belongs to, wherever it is opened.

**Enforcement.** `[test]` boundary test.

## W11. No import cycles [#w11-no-import-cycles]

**Rule.** `apps/web/src` has no import cycle between files (type-only imports are ignored). Features may depend on each other in both directions only through public roles or declared edges.

**Why.** Cycles make initialization order fragile and blur which layer owns what.

**Enforcement.** `[test]` the W11 case of `scripts/web-feature-boundaries.test.mjs`, which runs oxlint's `import/no-cycle` on `apps/web/src` with the web `tsconfig` (so `@/` imports resolve), inside `pnpm test`. It is a test rather than a lint rule for the same reason as D7 on the backend: the import plugin makes `pnpm lint` markedly slower ([ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape)).

## W12. Files stay short [#w12-files-stay-short]

**Rule.** At most 100 lines of code in `routes/**` and 250 in every other `.ts(x)` file: `features/**`, `platform/**` and the files outside them, such as `server.ts`, `router.tsx` and `testing/**` (blank lines and comments not counted). Tests, `locales/**` and `routeTree.gen.ts` are exempt.

**Why.** A route that grows past a wrapper is hiding a page; a component past 250 lines is usually two.

**Enforcement.** `[lint]` `max-lines` warning in [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/.oxlintrc.json). A warning does not fail `pnpm lint`; a new file adds none (the gate in the `domain-feature` skill).

## W13. Nothing server-only in the client [#w13-nothing-server-only-in-the-client]

**Rule.** The client boundary of `packages/config/oxlint-client-boundary.json` applies to all of `apps/web/src` except the server files listed in one override: `server.ts`, `routes/api/**`, `routes/[.]well-known/**`, `routes/__root.tsx` (it reads the request language through `createIsomorphicFn`), `**/*.server.ts(x)` and `platform/rpc/orpc.ts`. The `max-lines` overrides set no `no-restricted-imports` options, so they leave the boundary intact.

**Why.** It is the same boundary as the rest of the repository ([client/server boundary](/docs/architecture/client-server-boundary)).

**Enforcement.** `[lint]` the client boundary, unchanged · `[build]` `assert-client-bundle-clean.mjs`.

## W14. A loader fails the route only for the page's own data [#w14-a-loader-fails-the-route-only-for-the-pages-own-data]

**Rule.** In a route loader, `ensureQueryData` / `ensureInfiniteQueryData` (which throw) are for the one query the page is about: the list on `/todos`, the audit page, the user table, the assistant's status. Everything else a loader warms, and every loader that gathers several sections (`prefetchShell`, `prefetchDashboard`, `prefetchIntegrations`), uses `prefetchQuery` / `prefetchInfiniteQuery`, which cache the result or the error and never throw. Nothing that lists an external service object by object (storage, a provider's API) is prefetched on the server at all; the browser queries it after hydration and the hook exposes `isPending` / `isError` so the component renders a skeleton or an "unavailable" state, never a false zero. `retryQuery` makes no retry during the server render; the browser retries after hydration.

**Why.** The server render has one shot and one response: a thrown loader error is an HTTP 500 for the whole page, and a retry backoff is time to first byte for everyone. A thrown error is right only when the page cannot mean anything without that data. `files.stats` is the recorded case: an `ensureQueryData` on it in the dashboard loader turned an unreachable storage endpoint into `GET /dashboard` answering 500 after 3 s (two retries of a full storage listing), while every other route stayed fast.

**Example.** [`dashboard.queries.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/dashboard/dashboard.queries.ts) warms the todo totals, the activity feed and the flags with `prefetch*` and leaves `files.stats` to the browser; [`todo.queries.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.queries.ts) keeps `ensureQueryData` for the list, which is the page.

**Anti-pattern.** `Promise.all([ensureQueryData(a), ensureQueryData(b), ensureQueryData(c)])` for three independent cards; a loader that awaits a storage listing or a provider call so the first paint can show a count.

**Enforcement.** `[test]` `dashboard.queries.test.ts` (a rejecting or hanging source resolves the prefetch and `files.stats` is never queried), `app-shell.queries.test.ts`, `query-retry.test.ts` · `[convention]` for the choice between `ensure*` and `prefetch*` in a new loader.

## W15. Nothing before the first paint waits for a script [#w15-nothing-before-the-first-paint-waits-for-a-script]

**Rule.** A public page paints from its HTML and CSS alone. A stylesheet is a side-effect import (`import "../index.css"` in `__root.tsx`, a route's own CSS in its component), never a `?url` link in `head().links`: only manifest CSS can be inlined, and a request without a session gets it inlined (`firstPaintOptions` in `platform/runtime/first-paint.ts`, which also names the font in a `Link` header for Early Hints). The head carries no `modulepreload`: the root document renders `HeadContentWithoutScriptPreloads`, and `ScriptPreloadsAfterStyles` appends the route chunks' preloads from the end of the body, once the stylesheets before it have loaded. The client build keeps the modules every page loads at startup in three chunks (`startupChunks` in `vite.config.ts`, `tags: ["$initial"]`).

**Why.** On a slow connection the browser shares the pipe among everything the head names. With 65 `modulepreload` links next to the stylesheet the landing painted at 2.5 s on the measurement profile, against 1.2 s with every script blocked; with the CSS inline and the scripts after it, it paints at 0.7 s and hydrates as before. Numbers and method: [`docs/measurements/2026-11-05-first-paint.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/docs/measurements/2026-11-05-first-paint.md).

**Example.** [`script-preloads.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/app/script-preloads.tsx) and [`first-paint.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/runtime/first-paint.ts).

**Anti-pattern.** `{ rel: "stylesheet", href: css }` from a `?url` import; `<HeadContent />` back in the root document; a head `preload` of a script, an image or a data file without a measurement, since it competes with the CSS; a `codeSplitting` group without `tags: ["$initial"]`, which moves route-only modules into every page.

**Enforcement.** `[test]` `apps/web/src/platform/app/script-preloads.test.tsx`, `apps/web/src/platform/runtime/first-paint.test.ts` · `[manual]` `scripts/measure-first-paint.mjs` · `[convention]` for a new stylesheet or head preload.

## Locales [#locales]

New keys use the feature's prefix (`TODO_`, `ASSISTANT_`, `DOCS_`, …) in `apps/web/src/locales/{en,de,fr}.ts`, which stay central. Enforcement: `[convention]` for the prefix, `[test]` for parity between the three catalogs.

The catalogs are static objects with no spreads, so [x18n](https://x18n.dev) can read and edit them; error-code messages live in `packages/errors/src/locales` and join the catalog in `platform/i18n/catalogs.server.ts`. `pnpm i18n:check` reports missing keys, placeholder mismatches and unused keys for web and mobile (`apps/*/x18n.config.yml`), and `pnpm i18n:ui` opens the editor. A key passed as data rather than to `t()` counts as used when its property ends in `Key` (`titleKey: "HOME_…"`). Enforcement: a CI step (`pnpm i18n:check`, not part of `pnpm test`) fails on x18n errors; unused keys are warnings.

Only the server imports the catalogs: `platform/i18n/catalogs.server.ts`, loaded by `server.ts`. The browser gets the request's catalog from `/locales/{$lang}.json?v=<version>` (immutable): the root route preloads it from `<head>` (`catalogPreloadLink`), `client.tsx` awaits `loadBootCatalog()` before hydrating (`platform/i18n/boot-catalog.ts`), and an in-place language switch under the app shell fetches another one the same way (`switchLanguage`). Neither the HTML nor the client entry carries a catalog; a failed preload still hydrates and `recoverMissingCatalog` fetches it afterwards. Enforcement: `[test]` `boot-catalog.test.ts`, `intl.test.ts`, `catalog-cache-control.test.ts`. Nothing under `apps/web/src` outside `catalogs.server.ts`, the OG templates, the agent documents and the web manifest imports `@/locales/*` as a value; `MessageKey` and the i18next type declarations use `import type`. A test that translates imports `@/platform/i18n/catalogs.server`. See the 2026-10-30 amendment of [ADR-0012](/docs/adr/0012-web-features-mirror-the-backend-shape).

## Starting a new screen [#starting-a-new-screen]

Use the web section of the `domain-feature` skill ([`.agents/skills/domain-feature/SKILL.md`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/.agents/skills/domain-feature/SKILL.md)).

## Key files [#key-files]

- [`scripts/web-feature-boundaries.test.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/scripts/web-feature-boundaries.test.mjs)
- [`apps/web/src/.oxlintrc.json`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/.oxlintrc.json)
- [`apps/web/src/platform/rpc/orpc.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/rpc/orpc.ts)
- [`apps/web/src/platform/auth/session-query.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/auth/session-query.ts)
- [`apps/web/src/features/docs/docs.page-loader.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/docs/docs.page-loader.ts)
- [`apps/web/src/features/todo/todo.queries.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.queries.ts)
- [`apps/web/src/features/todo/todo.hooks.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.hooks.ts)
- [`apps/web/src/features/todo/todo.page.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/todo/todo.page.tsx)
- [`apps/web/src/routes/_auth/todos.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/routes/_auth/todos.tsx)
- [`apps/web/scripts/assert-server-fn-manifest.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/scripts/assert-server-fn-manifest.mjs)
- [`apps/web/scripts/assert-client-bundle-clean.mjs`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/scripts/assert-client-bundle-clean.mjs)
- [`apps/web/src/platform/app/script-preloads.tsx`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/app/script-preloads.tsx)
- [`apps/web/src/platform/runtime/first-paint.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/platform/runtime/first-paint.ts)

