Module boundaries
apps/web is one package with three import zones, and the whole standard is a single rule about which way an import may point.
flowchart LR app["app/ — browser only"] shared["shared/ — browser and server"] server["server/ — server only"] app --> shared server --> shared shared -. banned .-> app
app/ (reached as @/ or ~/) is client code — meaning no server module may import it, not that it only ever executes in a browser: the SSR render evaluates it in Node, which is browser execution's subject rather than this page's. server/ is Nitro code. shared/ (reached as #shared) is the ground both stand on: every module in it is parsed by the server and shipped to the browser. So app/ and server/ may import shared/ freely, and shared/ may import neither.
Why the direction matters
An import from shared/ into @/ is not a style complaint. It drags whatever the client module pulls in — a UI component library's types, a form vocabulary, a browser-only runtime value — into the graph the server evaluates, and it does so invisibly: the offending import usually sits several hops from the module a server route actually named. A validation schema that a tRPC procedure parses should not be able to fail because a component's prop type moved.
The direction also encodes the honest split of responsibility. shared/ states what a thing is — its fields, its constraints, the refinements that make a value valid anywhere. How that thing is rendered is a client question, and a client question answered inside shared/ is a boundary violation waiting to look like a schema.
Twins, not relocation
When a shared/ module is found reaching into @/, the fix is almost never to move the client module down into shared/ — that relocates the boundary instead of restoring it, and usually drags a UI dependency along. The fix is a twin: shared/ keeps the validating schema, and app/ gets a form schema derived from it.
Derived is the load-bearing word. A twin that restates its counterpart is two schemas that drift; a twin built with Zod's safeExtend layers presentation onto the shared schema and overrides nothing else, so a field, constraint or refinement added on the shared side reaches the form without being copied. Typing the result satisfies z.ZodType<TSharedType> closes the loop — an arm that stops matching what the server parses stops compiling.
The sheet column forms are the worked example. shared/models/resource/sheet/column/transformation/ holds transformation schemas with no presentation metadata at all; app/models/resource/sheet/column/transformation/ColumnTransformationForm.ts holds the form twin that names which context list feeds each source-column picker. The column form schemas themselves live wholly under app/ — nothing on the server ever parsed them.
Enforcement
An overrides entry in the root oxlint.config.ts scopes no-restricted-imports to apps/web/shared/** and bans the @/** and ~/** patterns. It catches type-only imports too, which matters — a .d.ts augmentation reaching into app/ is the same coupling with the runtime cost hidden.
Two things about that entry are easy to get wrong. Oxlint's path globs do not cross /, so the pattern must be @/** and never @/*. And an overrides entry replaces a rule's options rather than merging with them, so the repo-wide node:crypto ban has to be restated inside the override or it silently stops applying to shared/.
The server direction is deliberately not banned. A handful of shared/ modules reach into @@/server for a router's inferred types or to register a test double, which costs nothing at runtime.
Nor does the rule reach scripts/. The Tiled and Phaser code generators import from @/ for the tilemap property models they emit against — Node processes reading the browser tree, which never enters either runtime graph.
What stays in shared/ despite reaching nothing
The ban guards the import direction. It says nothing about the weaker case: a module that lives in shared/ and imports no client code, but which no server module ever uses. That is client code paying a shared-tree tax, and it is what drags client concerns back toward the boundary over time — so the default is that such a module moves to app/.
Two exceptions are deliberate, and are recorded here so an audit does not keep rediscovering them:
shared/generated/tiled/**— only three of its outputs are server-reached, but the generator hardcodes a single output root, so splitting them is a generator change rather than a relocation.shared/generated/phaser/**— currently has no consumer at all. It is retained deliberately: the generator was ported ahead of the code that will read it, and deleting the output without deleting the generator only recreates it.
A workspace package is imported where it lives
The zones above are about direction. The fourth import kind — a workspace package such as @esposter/db or @esposter/shared — has no direction question, and gets no local shim: a file under server/services/ whose whole body re-exports one name from a package is deleted and its callers import the package. The shim reads like an abstraction seam but is not one. Nothing can be swapped behind it, since the package is where the function is defined and the Azure Functions handlers reach it directly anyway, and it leaves the same function reachable under two names — so a grep for callers answers half, and two files disagree about which import path is idiomatic.
The seam that is worth keeping is the opposite shape: a local function that wraps package behaviour with something of its own, like readSurveyResponseEntities naming what "this survey's responses" means on top of getPartitionKeyFilter. The test is whether deleting the file would lose a decision.
No module cycle
An import never leads back to the module it came from, however many modules it passes through. A cycle is not a
type error and usually not a crash, since a function one module exports is only read when it is called — until
something reads a binding while its module is still evaluating, as an async component loader can, and the page throws
Cannot access '…' before initialization. The room's thread store did exactly that: it called the metadata read,
which reads files through the file store, which reads the thread store back.
flowchart LR T["thread store"] -- "was: useReadMessageMetadata" --> M["metadata read"] M --> F["file store"] F --> T O["useOpenThread"] --> T O --> M
A cycle is cut the ways the platform's own guide names: move the code that closes it to the module that owns it, or
into a third module both can reach. Here the read moved into useOpenThread, which passes it to the store the same
way a room page passes its query to the data store. Two stores may still reach each other through Pinia, which registers a
store before running its setup, but neither may read the other's state while setting up, and the modules under them
may not loop.
import/no-cycle refuses a cycle the written imports close. A Nuxt auto-import is written nowhere, so
app/moduleCycles.test.ts rebuilds the graph from .nuxt/imports.d.ts and the values each file references, and
fails whenever an auto-import closes one of its cycles.
Key files
| File | Role |
|---|---|
oxlint.config.ts | The apps/web/shared/** override carrying the ban |
apps/web/shared/types/zod.d.ts | Zod metadata both zones share |
apps/web/app/models/resource/sheet/column/transformation/ColumnTransformationForm.ts | The worked twin |
apps/web/app/moduleCycles.test.ts | The cycles only an auto-import closes |
apps/web/app/composables/message/thread/useOpenThread.ts | The worked cut |
Sources
- Cyclic imports, MDN: the
ReferenceErrora binding read before its module evaluated throws, and the cures — merge, move to a third module, or move code across. - Using a store in another store, Pinia: stores that use each other must not both read each other's state in setup.
- Temporal dead zone, MDN: why an uninitialised binding throws rather than reading
undefined.