Client and server boundaries
A *.page.tsx file is universal: its default component renders on the server
and in the browser. The client graph is determined by imports, not by a file
living under src/web/.
Put config (including config.metadata), loader, and action/actions in the matching
.setup.ts file. These stay on the server. The framework projects only
browser-safe register() code and its dependencies to the client. UI files
contain components and browser-safe imports. Use type-only imports from setup
to retain loader and action inference.
What the build refuses
Section titled “What the build refuses”The client build enforces the boundary at four points:
- Import resolution refuses Node built-ins, server-environment Warlock and
Mongez packages,
server-only,*.server.ts,.server/directories and applicationserver/directories. - The module boundary refuses an import of an
app/<module>/...file that is not browser-safe (see below). - Module transformation refuses every client-side
process.envread and any non-publicimport.meta.envkey. - Emitted-bundle validation checks that no server export binding or import edge into a server package survived projection.
The checks apply to development SSR as well as production builds. A violation fails loudly before deployment.
Read environment data in a loader
Section titled “Read environment data in a loader”process.env has no PUBLIC_ exception in the universal graph. These are all
refused when reachable from a component or universal helper:
process.env.API_URL;process.env.PUBLIC_API_URL;process.env[key];const { API_URL } = process.env;const all = { ...process.env };Object.keys(process.env);JSON.stringify(process.env);globalThis.process.env, window.process.env, and process["env"] are also
covered. Importing env() into a client-bound component does not work either;
@warlock.js/core is server-only.
Read the value from a loader and return the smallest safe payload:
import { env } from "@warlock.js/core";import type { PageLoader } from "@warlock.js/web";
export const loader = (async () => ({ publicApiOrigin: env("PUBLIC_API_ORIGIN"),})) satisfies PageLoader;import type { PageProps } from "@warlock.js/web";import type { loader } from "./example.setup";
export default function HomePage({ data }: PageProps<typeof loader>) { return <a href={data.publicApiOrigin}>API</a>;}The setup loader stays on the server, but its returned data crosses the hydration boundary. Never return a secret just because the read itself ran on the server.
The build-time public escape hatch
Section titled “The build-time public escape hatch”When a genuinely public value must be compiled into browser code, use Vite’s
surface with a static PUBLIC_ key:
export default function HomePage() { return <h1>{import.meta.env.PUBLIC_SITE_NAME}</h1>;}Since 5.21, import.meta.env.PUBLIC_* also works in server bundles (SSR), so server and client render the same value.
Vite’s built-ins (MODE, DEV, PROD, BASE_URL, and SSR) are also
allowed. These values are fixed when the bundle is built; they cannot vary per
request or deployment. Loader data remains the correct channel for anything
request-scoped.
What browser code may import from an app module
Section titled “What browser code may import from an app module”An application module (src/app/<module>/) is server code: controllers,
services, repositories, models, routes. Browser code, meaning everything the
client build reaches from a page, may import only these files from it:
| File | Why it is safe |
|---|---|
*.client.ts / *.client.tsx, or a .client/ folder | It declares itself browser code |
*.schema.ts, or anything in a schema/ folder | Shared validation, no server dependency |
*.types.ts, *.d.ts, or anything in a types/ folder | Types only |
The module’s web/ folder | Web code that lives beside the module |
| JSON, CSS and other non-code files | They carry no behavior |
Anything else fails the build with the import chain:
Gate A refused an import: browser code reaches a server file of an app module.
Import chain: orders.page.tsx → app/orders/services/create-order.serviceCause: "app/orders/services/create-order.service.ts" is a server file of module "orders" ...A type-only import is erased, so this is allowed and is the way to share the input and output types that sit next to a service:
import type { CreateOrderInput } from "app/orders/services/create-order.service";Both the relative and the app/... spelling are checked. By default
("warn") each offending import is reported and the build continues, so an
existing app upgrades without a broken build. Once your client code only imports
browser-safe module files, set moduleBoundary: "error" in src/config/web.ts
to fail the build instead. New apps from create-warlock start with "error",
and the default becomes "error" in a later release.
In "warn" mode each unique violation (importer and target) is printed once
per dev session, with its import chain. Repeats are only counted: a summary line
reports how many more warnings were suppressed. "error" mode is unchanged and
refuses every offending import.
const webConfigurations: WebConfigurations = { moduleBoundary: "error", // "warn" (default) | "error" | "off"};.client modules are a client-only boundary
Section titled “.client modules are a client-only boundary”A *.client.ts or *.client.tsx file is browser code. The guarantee is about
who may import it:
- Pages, layouts and components may import it. A static import is evaluated
by the server render too, so a
.clientmodule is not skipped during SSR. To keep a component out of the server render, wrap it in<ClientOnly>and load the module withReact.lazy(seerender-client-only). - Server-only code must not import it. Controllers, services, repositories,
models, routes, connectors,
main.ts, event files,*.server.tsfiles and the server side of a setup file (loader,action,middleware) fail with the import chain in development SSR, andnpx warlock checkreportsWL9022for the same import in CI. A type-only import is not reported.
In development, a .client module that reads window, document,
navigator, localStorage or another browser global outside a function while
the server evaluates it logs one warning naming the file and line. Move the
access into an effect or event handler, or guard it with
typeof window !== "undefined".
Intl output can differ between Node and the browser
Section titled “Intl output can differ between Node and the browser”Intl.DisplayNames, Intl.Collator and localeCompare depend on the ICU data
of the runtime, which differs between Node and browsers. Text or ordering built
from them during the server render can disagree with the hydrated render and
cause a hydration mismatch. Render it from a shared pre-computed table or after
hydration; see Localization.
Static assets
Section titled “Static assets”Imported non-stylesheet assets are not supported by the production server
build in 5.2. Put them in public/ and reference their root URL:
public/logo.svg → /logo.svgStylesheet imports are supported and remain part of the matched page’s initial CSS chain.