Skip to content
Docs

Command palette

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

Changelog

Client/server boundary

Keep server capabilities out of browser and native module graphs.

Markdown

Import a leaf

A server-capable module must not re-export a neighbor's convenience constant. A client that imports one enum from a Drizzle table module can pull the entire table graph into its bundle. That happened here and led to ADR-0002.

import { PRIORITIES } from "@template-cloudflare/db/schema/todo.enums";
import type { AppRouterClient } from "@template-cloudflare/api/client";

Domain enums live in dependency-free *.enums.ts modules. Public Zod contracts live in api/src/features/*/*.contract.ts, importing only safe contracts where necessary. A LEAF MODULE header means no imports may be added to that module. The API client entry exports types only.

Enforcement

  1. Nested Oxlint configs reject value imports of server modules from web, mobile and UI. Type-only imports are allowed.
  2. Server package export maps enumerate supported subpaths. Add a specific entry deliberately; do not widen it to ./*.
  3. The web build scans emitted client assets for server-only markers with assert-client-bundle-clean.mjs.
pnpm exec oxlint
pnpm --filter web build

The Drizzle schema aggregate is the documented exception: the relations builder needs all tables, and clients cannot reach it. The existing web oRPC adapter also uses a framework-stripped server branch for SSR. Do not copy that exception into an ordinary component.

Server-only modules in the web app

Inside apps/web/src, a module that only the server may load ends in .server.ts or .server.tsx (ADR-0012). TanStack Start's import protection denies **/*.server.* files to the client environment by default. In vite build, such an 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 same protection denies **/*.client.* on the server, so no web file uses .client. in its name.

Client code reaches a .server module only through two isomorphic bridges, which use it inside createIsomorphicFn().server() so the compiler strips the branch from the client build:

The oRPC client, platform/rpc/orpc.ts, is the exception named above: it imports no .server file, but value-imports the backend router for in-process SSR inside its own .server() branch, and the web lint config carves it out of the boundary by path.

The web has no createServerFn. Server routes (routes/api/**, routes/[.]well-known/**, and handler-only resource routes such as docs[.]md.ts or sitemap[.]xml.tsx) and server.ts import .server modules directly; the boundary test (scripts/web-feature-boundaries.test.mjs, rule W3 in web conventions) refuses any other importer.

Extending a contract

Put the shared value in a leaf first. Make the table and schema import the leaf. Add its explicit package export, then import that address from consumers. Do not rely on tree-shaking to hide server side effects.

Documentation articles render on the server so examples of server-only names stay out of executable client assets too. The browser receives trusted, repository-authored HTML and small navigation metadata.

Key files

Edit on GitHubDocumentation is in English.