Skip to content

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.

The client build enforces the boundary at four points:

  1. Import resolution refuses Node built-ins, server-environment Warlock and Mongez packages, server-only, *.server.ts, .server/ directories and application server/ directories.
  2. The module boundary refuses an import of an app/<module>/... file that is not browser-safe (see below).
  3. Module transformation refuses every client-side process.env read and any non-public import.meta.env key.
  4. 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.

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:

src/web/example.setup.ts
import { env } from "@warlock.js/core";
import type { PageLoader } from "@warlock.js/web";
export const loader = (async () => ({
publicApiOrigin: env("PUBLIC_API_ORIGIN"),
})) satisfies PageLoader;
src/web/example.page.tsx
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.

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:

FileWhy it is safe
*.client.ts / *.client.tsx, or a .client/ folderIt declares itself browser code
*.schema.ts, or anything in a schema/ folderShared validation, no server dependency
*.types.ts, *.d.ts, or anything in a types/ folderTypes only
The module’s web/ folderWeb code that lives beside the module
JSON, CSS and other non-code filesThey 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.service
Cause: "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.

src/config/web.ts
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 .client module is not skipped during SSR. To keep a component out of the server render, wrap it in <ClientOnly> and load the module with React.lazy (see render-client-only).
  • Server-only code must not import it. Controllers, services, repositories, models, routes, connectors, main.ts, event files, *.server.ts files and the server side of a setup file (loader, action, middleware) fail with the import chain in development SSR, and npx warlock check reports WL9022 for 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.

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

Stylesheet imports are supported and remain part of the matched page’s initial CSS chain.