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): bind a
'use server'action into the form and return 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 form-binding 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.
The boundary renders inside the layouts at and above its own segment, so your site chrome stays on screen: the nav, the sidebar and the header are still there, and the user has something to navigate away with. It also means a client-router navigation into a failing page stays a soft navigation rather than reloading the document, so hydrated state elsewhere on the page survives. A layout deeper than the boundary never rendered on the way in, so it is not rendered on the way out either.
Because the boundary sits inside its own segment's layout, that layout cannot be caught by it. This matches Next's component hierarchy. What happens instead depends on which boundary is rendering:
- On a 500, the error walks outward: each
error.tsin the chain is tried in turn, innermost first, and a layout that throws fails every attempt whose layouts include it, so control ends atglobal-error.ts(or the built-in 500 page) once they are exhausted. - On a 404, 403 or 401 there is no outward walk. Each renders the single nearest boundary, so a layout that throws degrades that response to the boundary on its own, without the surrounding chrome and without a boot script. The status is preserved. A
redirect()thrown by one of those layouts is discarded rather than followed, because the status is already decided and the boundary page is the answer to that request.
A layout that genuinely crashes is reported to your onError hook rather than being swallowed, so it reaches your error tracker. Repeats of the same crash within one request are collapsed to a single report, since one shared layout can fail several boundary attempts. A redirect() or notFound() is never reported, being routing rather than a crash.
The same holds when the boundary file itself throws or fails to load. Its response body follows the rule the rest of the framework uses for a thrown error: the failure is shown in development and withheld in production, where the page carries only its status, because a thrown message is not something you control and may name a driver, a path or a connection string. The error still reaches onError and the server log either way, so sanitizing the response never means losing the failure.
Two consequences worth knowing. A layout that fetches data runs that fetch a second time on a boundary response, since the chain is rendered again around the boundary. And a <webjs-suspense> inside a wrapped layout shows its fallback, because a boundary response is buffered so its status and headers are final before the first byte goes out.
// 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 form-bound 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):
It is the one boundary that is not wrapped in layouts. It writes its own document shell, wrapping it in the root layout would re-run the code that just threw, and it ships no importmap or boot script by design. So a navigation into global-error.ts is a full page load, which is the right outcome for a last-resort page.
// 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 <script> the cspNonce(). An inline <style> needs one only if you tighten style-src, since the default policy allows inline style outright.
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.
A directive that throws mid-commit stays consistent
The component boundary above also covers watch(signal) and until(), which commit outside the update cycle, so a throw from either reaches renderError() rather than the window. It reaches the component whose template holds the binding, which is not always the element the binding sits inside: a watch() written between a child component's tags belongs to the parent that wrote it. asyncAppend / asyncReplace are covered the same way: a chunk's own commit throw, and a watch() or until() nested inside a chunk, both reach the owning component's renderError(). A chunk's own commit throw also stops the stream, since the boundary is about to render an error state and appending into a region it may have replaced is not a recovery; a nested directive throws from its own handler outside that loop, so it reaches the boundary without stopping the stream. Your own code is the exception: a throw from the iterable or from a mapper you passed alongside it is a generator failing rather than a render, so it is still logged to the console and you are expected to handle it. That ends the stream too.
Beyond reporting the error, the directive's own state is left describing the DOM that actually exists, which is what makes the NEXT render correct. That matters because the failure is otherwise silent: the renders that expose it are fully valid and log nothing. The hole whose commit threw is marked so the next render re-applies it instead of skipping it as unchanged, which is what used to leave a region blank for good. Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would throw away the node identity they exist to preserve. repeat() re-unites its key map and repositions every row (the symptom was a permanently duplicated row). A plain .map() array splices back the part of its slot list the failed pass never reached, which is what a slot REPLACED rather than updated in place needs (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the symptom was a stranded row that outlived even a render of an empty array). guard() records its new deps only once the commit succeeds, so a later render with those deps re-renders the region instead of skipping past one the throw had blanked; until() advances its resolved priority only after its commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
Tearing content back out is covered too, and it has to be, because a teardown has no next render to repair it. Unbinding a ref while a row is removed can never abort the removal of the rest of the list, and repeat() drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed. Without that, a throw part-way through left the row you had DELETED on screen, reordered the survivors, and let a later render that re-added the key reinsert the disposed instance. The cost is that a ref whose object value setter throws is swallowed on teardown, matching the ref callback, which was already swallowed everywhere (lit guards neither and propagates from both, so this is a deliberate divergence). It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches renderError(). Covered also means a removal takes the row's own boundary markers with it, so a list that grows and shrinks all day is net zero on the nodes the renderer added, rather than accruing one invisible comment per removed row for the life of the region.
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.
A render error's overlay is scoped to the page that produced it. It comes down when you navigate away, it never appears in a tab that is viewing a different page, and merely prefetching a link to a broken page (which happens on hover, since link prefetch is on by default) does not raise one on the page you are actually on. A rebuild or TypeScript error is not page-scoped, because it describes a broken build rather than one route, so it stays put until the next successful rebuild.
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