Error Handling
WebJs provides nested error boundaries via error.js/error.ts files, plus component-level error handling via renderError(). Errors are caught at the nearest boundary and rendered without crashing the entire page.
When to use
- Show a user-friendly error page when a route or layout throws during rendering.
- Isolate failures in one section of the page from the rest (e.g. a broken sidebar shouldn't crash the whole layout).
- Catch errors from async page functions, server actions, or database queries.
When NOT to use
- For 404 pages: use
not-found.tsinstead, or thrownotFound()from a page function. -
For form validation errors there are two valid patterns, neither of which uses error boundaries:
- JS-side: handle validation in the component's submit handler, keep errors in component state.
- Server-rendered (Rails / Django / Laravel style): export a page
actionthat returns a failureActionResult({ success: false, fieldErrors, values, status: 422 }). The framework re-SSRs the SAME page at422 Unprocessable Entitywith the result onctx.actionData, so the page repopulates inputs fromactionData.valuesand shows messages fromactionData.fieldErrors(no hand-rollednew Response(...)). The client router applies that response in place regardless of status code, so the user sees the validated form without a full page reload and without losing their typed values. See the server actions docs for the page-action pattern and the client router docs for the rendering behavior.
Route-level error boundaries
Place an error.ts file at any level in the app/ directory. When a page or layout at that level (or deeper) throws, the nearest error.ts is rendered instead.
// app/error.ts: root error boundary
import { html } from '@webjsdev/core';
export default function ErrorPage({ error }: { error: Error }) {
return html`
<h1>Something went wrong</h1>
<p>${error.message}</p>
<a href="/">Go home</a>
`;
}
Nesting
Error boundaries are nested. The framework walks from the throwing component outward until it finds the nearest error.ts:
app/
error.ts ← catches errors from any page
blog/
error.ts ← catches errors from /blog/* pages only
[slug]/page.ts ← if this throws, blog/error.ts handles it
If blog/error.ts also throws, the parent app/error.ts catches it.
not-found.ts
A special error boundary for 404 responses. Place not-found.ts at any route level, and the nearest one wins:
// app/not-found.ts
import { html } from '@webjsdev/core';
export default function NotFound() {
return html`
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
`;
}
Trigger a 404 programmatically from any page function or server action:
import { notFound } from '@webjsdev/core';
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) notFound(); // renders nearest not-found.ts
return html`...</h1>`;
}
forbidden.ts and unauthorized.ts
Throw forbidden() (403) or unauthorized() (401) from a page/layout function or a page action, the same way as notFound(). The nearest forbidden.ts / unauthorized.ts boundary renders (a default page when none exists). Use unauthorized() for a request that is not authenticated, and forbidden() for an authenticated user who lacks permission:
import { forbidden, unauthorized } from '@webjsdev/core';
export default async function AdminPage() {
const user = await currentUser();
if (!user) unauthorized(); // renders nearest unauthorized.ts (401)
if (!user.isAdmin) forbidden(); // renders nearest forbidden.ts (403)
return html`...`;
}
Inside a 'use server' RPC action (one a client component calls), return a { success: false, error, status } ActionResult for an auth failure rather than throwing forbidden() / unauthorized(). The boundary render is a page-routing concern, the same guidance as for notFound() / redirect().
global-error.ts and global-not-found.ts
Two root-only boundaries (in app/ exactly). global-error.ts is the app-wide catch-all, tried after the nested error.ts boundaries are exhausted, and it renders its OWN full document (a root-layout failure is when it fires):
// app/global-error.ts
import { html } from '@webjsdev/core';
export default function GlobalError({ error }: { error: Error }) {
return html`
<!doctype html>
<html><body><h1>Something went wrong</h1></body></html>
`;
}
Keep global-error.ts static (no components / hydration): it is returned verbatim with no importmap or boot script, so it must not depend on the module system that may have just failed. Under an opt-in CSP, give any inline <style> the cspNonce().
global-not-found.ts renders for a URL that matches nothing anywhere, when no not-found.ts applies.
Component-level error handling
Override renderError(error) in any WebComponent to catch errors from that component's render() method:
class MyWidget extends WebComponent {
render() {
// If this throws, renderError() is called instead
return html`<div>${this.riskyComputation()}</div>`;
}
renderError(error: Error) {
return html`<p class="error">Widget failed: ${error.message}</p>`;
}
}
If renderError() is not defined, the error is logged to the console and the component's shadow root shows the last successful render (or nothing on first render).
Per-component error isolation is automatic (async render)
For a component with an async render(), error isolation is a default that needs no user code. A thrown await getData() (or any render throw) is caught for THAT component: its siblings render normally and the failure never bubbles to the route error.ts. On the server the default renders a component-scoped error box in dev and a silent empty element in prod (no internal detail leaks); on the client the same boundary runs. Add renderError() only to customize the error UI. This delivers a per-route-error-boundary experience at the component level, without per-component routes.
Server action errors
Errors thrown from server actions are sanitized in production: the client gets a generic "Internal server error" message plus a short digest, never the raw thrown message or the stack trace. The full error is logged server-side keyed by that digest, so a client-reported digest maps back to the server log line. A redirect() / notFound() control-flow throw passes through. To surface a specific user-facing message, return an ActionResult { success: false, error } envelope instead of throwing.
Dev error overlay
In development, an SSR render crash, a non-erasable-TypeScript strip failure, and a failed rebuild each push a rich error overlay to the open tab over the live-reload channel, without a manual refresh. The overlay shows the message, the offending file:line:column, and a source code frame of the failing line with context. A TypeScript strip failure also shows the erasable-syntax hint inline (a non-erasable enum / namespace breaks only the client module fetch, so the page still server-renders but hydration is dead; the overlay surfaces that instead of burying the hint in a console comment). The overlay dismisses on the next successful rebuild, and the frame is replayed to a tab opened after the breaking edit.
This is strictly a development feature. In production the error response stays terse (only message, never the stack or any file path), and the overlay client is never served, so nothing about your source leaks. An embedding host can observe the same frames via the onDevError option on createRequestHandler / startServer.
Next steps
- Routing: file conventions for pages, layouts, and error boundaries
- Loading States:
loading.tsfor Suspense boundaries - Server Actions: error handling in RPC calls