Skip to content

Layouts and special pages

A layout.tsx holds the React wrapper. Its layout.setup.ts supplies a route prefix, middleware, metadata, and a loader to pages below its directory. See Page setup files for all pairs.

Every layout.tsx from the Web root down to the page contributes outermost first:

  • Middleware always composes. Every ancestor layout with config.middleware participates, whether the layout renders a component or not.
  • Rendering is capped at one layout. A layout counts only when it has a default export. Prefix/middleware-only layouts may nest freely; two rendering layouts on one page path fail the build and name every offender.
  • A prefix replaces its directory segment for derived routes. If src/web/products/layout.setup.ts declares prefix: "/store", then src/web/products/index.page.tsx resolves to /store, not /store/products.
  • A prefix prepends an explicit page path. The composed prefix joins the literal config.route.path; root values do not create doubled slashes.
src/web/products/layout.setup.ts
import type { LayoutConfig } from "@warlock.js/web";
export const config: LayoutConfig = { prefix: "/store" };
src/web/products/layout.tsx
import type { LayoutProps } from "@warlock.js/web";
export default function StoreLayout({ children }: LayoutProps) {
return <section className="store-shell">{children}</section>;
}

Layouts can also provide inherited metadata (including robots) and sitemap: false inside this same LayoutConfig. They cannot set a page route, cache policy, validation, or a sitemap URL supplier. root.setup.ts exports config: RootConfig for application middleware, strictMode, and metadata; the browser hydrates the complete document with the same root, layout and page ancestry used by SSR. Client navigation replaces the layout/page tree inside #vessel and preserves the document root.

React useId() and a Mongez Form without an explicit ID work beneath a layout. Initial hydration includes the root document and its sibling positions, so IDs captured by client handlers match the SSR attributes, labels and ARIA references. Repeated instances receive distinct IDs; no global ID replacement or hydration warning suppression is needed.

Root components now render in the browser too. Keep server-only reads in root setup loaders and pass serializable results through data. Keep one #vessel wrapper around the root’s {children} for client navigation.

Fixed in 5.12. A layout or page config.middleware declaration runs as part of the same composed chain described above — outermost layout first, the page’s own config.middleware last. What a middleware function returns decides what happens next:

  • Returns nothing (undefined). The chain continues to the next middleware, then to validation and the loader — an ordinary pass-through guard.
  • Already wrote the real reply itself — response.redirect(...), response.forbidden(...), or any other call that reaches response.send() — the client already has the real answer. Whatever the function also returns is ignored; nothing re-renders on top of it.
  • Returns a value with a status >= 400 without writing the reply itself — for example response.setStatusCode(403); return { error }; — renders your error.page.tsx boundary with that status, with the returned value attached to the error.
  • Returns a value with a 2xx status without writing the reply itself — a plain return { message } (status stays at the default 200) — sends that value as the response body, unchanged (JSON-stringified when it’s an object). A page middleware returning 2xx content replaces the page; no loader or render runs.
src/web/account/layout.setup.ts
import type { LayoutConfig } from "@warlock.js/web";
export const config: LayoutConfig = {
middleware: [
({ request, response }) => {
if (!request.locals.user) {
response.setStatusCode(403);
return { error: "Sign in required" }; // >= 400 → error.page.tsx boundary
}
},
],
};

Before this fix, a middleware that returned a value without writing the reply itself produced a blank document at that status — the returned value was recorded but never used. Client navigations (data requests) were and are unaffected either way: their { html: "", status, ... } contract doesn’t change.

404.page.tsx is the application-owned not-found page. It has no browsable route of its own and must not declare config.route. When no application file exists, Warlock still answers unmatched URLs with its built-in 404 response.

Keep its metadata in the matching setup file:

src/web/404.setup.ts
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = {
metadata: { title: "Page not found", robots: "noindex" },
};
src/web/404.page.tsx
export default function NotFoundPage() {
return <main>We could not find that page.</main>;
}

The 404 component renders inside root.tsx with no layout. Its page-level loader is skipped, so an unmatched URL cannot trigger page data work, redirect, or fail through the fallback. Its page middleware and register() still run, and the root application loader still runs; keep root work cheap and tolerant of an unmatched request.

forbidden.page.tsx is the application-owned page for a refused request. A page route never answers a raw JSON error: a 401 or a 403 renders this page, with that status. That covers a failed requireUser({ userType }) or requireUser({ when }) check, and any loader or middleware that throws a Core ForbiddenError or UnAuthorizedError. A guest who has a sign-in page configured (auth.pageAuth.loginPath) is still redirected to it; only a refusal with nowhere to redirect reaches the forbidden page. API routes keep their JSON answers.

Core also refuses some requests before any route runs: a path with dot segments such as /public/../x (403), or a malformed URL (400). On an app that serves pages, a request that accepts HTML (an explicit text/html in Accept, GET or HEAD) gets the same page a thrown refusal gets — the forbidden page for a 401 or 403, otherwise error.page.tsx, then the framework default — with the refused status. API clients and other requests keep core’s JSON answer.

src/web/forbidden.setup.ts
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = {
metadata: { title: "Access denied", robots: "noindex" },
};
src/web/forbidden.page.tsx
import type { ErrorPageProps } from "@warlock.js/web";
export default function ForbiddenPage({ status }: ErrorPageProps) {
return (
<main>
<h1>{status === 401 ? "Please sign in" : "You don't have access to this page"}</h1>
<a href="/">Back to home</a>
</main>
);
}

It follows the same rules as error.page.tsx: it declares no config.route, a second copy fails the build (one per site in multi-site mode, at $sites/<name>/forbidden.page.tsx), robots: "noindex" stays on, and it receives the same { error, status } props, plus debug outside production. The request renders the first of these that exists:

  1. the site’s (or the app’s) forbidden.page.tsx;
  2. its error.page.tsx, with the 401 or 403 status;
  3. the framework default: a plain, accessible page with the request id, the development details outside production, and a “Back to home” link.

Hydration makes the same choice, so the page the server rendered is the page the browser hydrates. The framework’s own fallback for any other failure also shows the request id and a “Back to home” link.

To refuse a page request from your own middleware, throw (throw new ForbiddenError("Forbidden")) instead of calling response.forbidden(). A thrown 401/403 renders this page; response.forbidden() writes its JSON body straight to the wire, as the previous section describes.

error.page.tsx is the one application-wide error boundary. A second copy beneath src/web/ fails the build. It does not declare config.route and does not handle ordinary 404s.

Use error.setup.ts for error-page metadata and registration:

src/web/error.setup.ts
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = {
metadata: { title: "Something went wrong", robots: "noindex" },
};
src/web/error.page.tsx
import type { ErrorPageProps } from "@warlock.js/web";
export default function ErrorPage({ error, status }: ErrorPageProps) {
return (
<main>
<h1>Something went wrong</h1>
<p>Status: {status}</p>
</main>
);
}

The error page renders with the real status, a 4xx (a thrown HttpError such as ForbiddenError, or a failed page validation) as well as a 5xx, and hydrates in both cases.

In development, error is the thrown value during SSR. After hydration it is a JSON-safe { name, message, stack?, errorCode?, errors? } record; errors is present only for a failed page validation. In production, SSR and hydration both receive the same sanitized record. The error’s own message is replaced by a generic one, stack is never sent, and an opaque errorCode matches the line in the server’s error report. Error metadata may improve on the framework default but cannot remove robots: "noindex".

When a message is meant for the visitor, throw a PublicPageError. Its message is shown as written in every environment, during SSR and after hydration:

import { PublicPageError } from "@warlock.js/web";
throw new PublicPageError("This listing has expired.");

If a root or page module cannot load, or register() throws, Warlock tries the application boundary and then falls back to a minimal framework document when application code is no longer trustworthy. That fallback omits hydration so the browser never hydrates a tree the server could not establish.

There is no 500.page.tsx; all failures except 404 and 401/403 use error.page.tsx.

Every unhandled error response is forced to Cache-Control: private, no-store at the framework’s shared error funnel. This applies to every status and includes API-route failures; an intermediary must never replay one request’s error to another user.

Every page request that answers 5xx writes one error-level log record, once, however many stages saw the error. It goes to the logger channels you configured (and to console.error when none is configured), so a failure is never reduced to the Completed Request: … 500 line. The record’s module is web, its action is page.<phase>, and its context carries:

FieldMeaning
name, messageThe thrown error
stackThe stack, mapped to source files (the dev server maps it; in production run Node with --enable-source-maps)
phaseThe stage that failed (below)
levelapp, layout or page, for middleware, session and loader
fileThe matched page file
routePath, siteThe route pattern and the web.sites key serving the request
method, pathname, requestIdThe request, without its query string
PhaseFailure
translationsBinding the route’s translations (for example a locale the page has no table for)
module-loadLoading the app, layout or page module
middlewareAn app, layout or page middleware threw
sessionweb.session threw while resolving the viewer
loaderAn app, layout or page loader threw or timed out
metadataResolving metadata or document slots
renderA component threw while rendering, including errors React hands to an ErrorBoundary
streamReact’s shell or stream failed (onShellError, onError)
deferA deferred value rejected
error-pageerror.page.tsx itself failed
named-api-routesComputing the route table the document embeds for named API routes
request-handlerAnything else that escaped the page handler
unknownThe response is 5xx but no stage reported an error, for example response.setStatusCode(500) without a throw

A failure that ends as a 4xx (not found, a guard redirect, a failed page validation, a thrown HttpError below 500) is the visitor’s affair and is not logged as an error.

The same record is written for a 5xx from an API route, a controller or a route middleware, with module: "http"; see the 5xx error record.

Outside production, the error page also shows the same facts. Every error page, authored ErrorBoundary and the framework’s fallback is followed by a development panel with the message, phase, page file, route, site and stack, and an error.page.tsx receives them as debug on its props (PageErrorDetails). In production the panel and debug are absent and the page stays generic: the message and stack never reach the response, and the errorCode joins it to the log record.

Every server and client failure already logs unconditionally — that floor can never be turned off. On top of it, web gives you two app-owned hooks for sending the same failures to your own error-tracking service (Sentry, Bugsnag, your own endpoint, anything). Neither hook ever replaces the console floor, and there is no framework-hosted beacon endpoint — you decide where the error goes.

Configure a report function under the web.errors namespace. It receives the thrown value and a context object describing where the failure happened — never the raw request URL or query string, only the route’s own template path and the decoded pathname, so a token or secret sitting in a query parameter never reaches your sink by accident.

src/config/web.ts
import * as Sentry from "@sentry/node";
export default {
errors: {
report(error, context) {
Sentry.captureException(error, {
tags: { kind: context.kind, phase: context.phase, routeName: context.routeName },
extra: {
file: context.file,
site: context.site,
routePath: context.routePath,
pathname: context.pathname,
method: context.method,
statusCode: context.statusCode,
requestId: context.requestId,
},
});
},
},
};

report is called fire-and-forget — it is never awaited on the response path, so a slow or hanging call to Sentry cannot delay or fail a real HTTP response. The same error object reported twice for one request (a render failure a deferred value also surfaces, for example) reaches report once. A report that throws or rejects is caught, logged once to the console, and never retried for that failure — it cannot recurse into itself and it cannot take the server down. On a graceful shutdown, any report calls still in flight get a brief, bounded window to finish before the process exits.

A config file runs before the application boots, in every process that reads config. If your report function calls an application service, importing it from src/config/web.ts loads that service’s whole module graph at startup, and a lazy await import() inside the function still puts the target in the config’s dependency graph. Every test worker pays for it again. Keep config files free of src/app imports and register the hook from a module’s main.ts instead:

src/app/monitoring/main.ts
import { onPageError } from "@warlock.js/web/connector";
onPageError(async (error, info) => {
const { reportToTracker } = await import("./services/tracker.service");
await reportToTracker(error, info);
});

onPageError(hook) receives the same (error, context) as web.errors.report, runs through the same bounded, fire-and-forget queue, and runs in addition to web.errors.report when both are set. Several hooks may be registered; it returns a function that removes the hook. Import it from @warlock.js/web/connector, which stays free of React.

warlock doctor warns (config-app-imports) when a file under src/config imports anything from src/app, naming the file and the import.

Register a callback from your own client code — typically a client helper called by src/web/root.setup.ts’s register() or wherever your app initializes — with onClientError. It receives every uncaught window error, unhandled promise rejection, and React hydration error the framework observes, plus whatever the framework’s own error boundary catches after hydration.

src/web/client-errors.ts
import * as Sentry from "@sentry/react";
import { onClientError } from "@warlock.js/web";
export function registerClientErrors() {
if (typeof window === "undefined") return;
onClientError(({ error, kind, routeName, pathname }) => {
Sentry.captureException(error, { tags: { kind, routeName }, extra: { pathname } });
});
}
src/web/root.setup.ts
import { registerClientErrors } from "./client-errors";
export function register() {
registerClientErrors();
}

Like the server hook, onClientError sits alongside console.error, never in place of it, dedupes the same error object across overlapping reporting paths, and isolates its own failures — a callback that throws is caught, logged once, and never called again for that same failure. There is deliberately no client-to-server beacon endpoint yet; wire your own tracking SDK, or fetch() your own endpoint, from inside the callback.

Declare a synchronous, no-argument register() in root.setup.ts, layout.setup.ts, or the page’s matching .setup.ts file. It runs once per module namespace in both realms, before middleware or loaders. It must not return a Promise. The framework projects its browser-safe code to the client; keep its imports and dependencies safe for both server and browser.

src/web/index.setup.ts
import { groupedTranslations } from "@mongez/localization";
export function register() {
groupedTranslations({ welcome: { en: "Welcome", ar: "مرحبًا" } });
}
src/web/index.page.tsx
import { useTrans } from "@warlock.js/web";
export default function HomePage() {
const trans = useTrans();
return <main>{trans("welcome")}</main>;
}

Document and navigation data responses carry the same hydration payload, including the resolved document slots (stylesheets, fonts, locale routing and hydration module URLs). These slots let the browser reproduce the server root during initial hydration.