# 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–W13) 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, `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`, `@/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. 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`.

## 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.

## 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)

