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.
Layout composition
Section titled “Layout composition”Every layout.tsx from the Web root down to the page contributes outermost
first:
- Middleware always composes. Every ancestor layout with
config.middlewareparticipates, 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.tsdeclaresprefix: "/store", thensrc/web/products/index.page.tsxresolves 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.
import type { LayoutConfig } from "@warlock.js/web";
export const config: LayoutConfig = { prefix: "/store" };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.
Generated form identifiers
Section titled “Generated form identifiers”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.
Page middleware return values
Section titled “Page middleware return values”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 reachesresponse.send()— the client already has the real answer. Whatever the function alsoreturns is ignored; nothing re-renders on top of it. - Returns a value with a status
>= 400without writing the reply itself — for exampleresponse.setStatusCode(403); return { error };— renders yourerror.page.tsxboundary 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.
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.
The not-found page
Section titled “The not-found page”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:
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = { metadata: { title: "Page not found", robots: "noindex" },};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.
The forbidden page
Section titled “The forbidden page”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.
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = { metadata: { title: "Access denied", robots: "noindex" },};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:
- the site’s (or the app’s)
forbidden.page.tsx; - its
error.page.tsx, with the 401 or 403 status; - 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.
The error boundary
Section titled “The error boundary”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:
import type { PageConfig } from "@warlock.js/web";
export const config: PageConfig = { metadata: { title: "Something went wrong", robots: "noindex" },};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.
Debugging a failed page request
Section titled “Debugging a failed page request”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:
| Field | Meaning |
|---|---|
name, message | The thrown error |
stack | The stack, mapped to source files (the dev server maps it; in production run Node with --enable-source-maps) |
phase | The stage that failed (below) |
level | app, layout or page, for middleware, session and loader |
file | The matched page file |
routePath, site | The route pattern and the web.sites key serving the request |
method, pathname, requestId | The request, without its query string |
| Phase | Failure |
|---|---|
translations | Binding the route’s translations (for example a locale the page has no table for) |
module-load | Loading the app, layout or page module |
middleware | An app, layout or page middleware threw |
session | web.session threw while resolving the viewer |
loader | An app, layout or page loader threw or timed out |
metadata | Resolving metadata or document slots |
render | A component threw while rendering, including errors React hands to an ErrorBoundary |
stream | React’s shell or stream failed (onShellError, onError) |
defer | A deferred value rejected |
error-page | error.page.tsx itself failed |
named-api-routes | Computing the route table the document embeds for named API routes |
request-handler | Anything else that escaped the page handler |
unknown | The 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.
Reporting errors to your own service
Section titled “Reporting errors to your own service”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.
Server: web.errors.report
Section titled “Server: web.errors.report”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.
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.
Server: onPageError (register at runtime)
Section titled “Server: onPageError (register at runtime)”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:
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.
Client: onClientError
Section titled “Client: onClientError”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.
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 } }); });}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.
The universal register() hook
Section titled “The universal register() hook”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.
import { groupedTranslations } from "@mongez/localization";
export function register() { groupedTranslations({ welcome: { en: "Welcome", ar: "مرحبًا" } });}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.