These rules apply to apps/web/src. They mirror the backend conventions and are recorded in ADR-0012. Each rule has an ID (W1–W13) that the boundary test, the enforcement matrix and reviews refer to. Enforcement levels are explained on the enforcement page.
The shape
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
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 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
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
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 and features/docs/docs.page-loader.ts; each uses the .server module only inside createIsomorphicFn().server().
The oRPC client, 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
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
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 reads its data with useTodos() from todo.hooks.ts and passes it to todo.board.tsx as props; the route's loader prefetches through 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
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()
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
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).
Enforcement. [test] boundary test.
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
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
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).
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. 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
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).
Enforcement. [lint] the client boundary, unchanged · [build] assert-client-bundle-clean.mjs.
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
Use the web section of the domain-feature skill (.agents/skills/domain-feature/SKILL.md).
Key files
scripts/web-feature-boundaries.test.mjsapps/web/src/.oxlintrc.jsonapps/web/src/platform/rpc/orpc.tsapps/web/src/platform/auth/session-query.tsapps/web/src/features/docs/docs.page-loader.tsapps/web/src/features/todo/todo.queries.tsapps/web/src/features/todo/todo.hooks.tsapps/web/src/features/todo/todo.page.tsxapps/web/src/routes/_auth/todos.tsxapps/web/scripts/assert-server-fn-manifest.mjsapps/web/scripts/assert-client-bundle-clean.mjs