Error Handling

emroute handles errors at three levels. Each level catches what the level below it cannot. When something breaks, the most specific handler wins.

Layer 1 — Widget Errors (inline)

Every widget has built-in error rendering. When a widget's getData() or render method throws, the error is caught and rendered inline. The rest of the page continues rendering normally.

HTML context — renders a <div> with the error message:

<div data-component="crypto-price">Error: fetch failed</div>

Markdown context — renders a blockquote:

> **Error** (`crypto-price`): fetch failed

Override renderError() or renderMarkdownError() on any widget to customize the output:

class MyWidget extends WidgetComponent {
  override readonly name = 'my-widget';

  override renderError({ error }: { error: unknown }) {
    const msg = error instanceof Error ? error.message : String(error);
    return `<div class="widget-error"><p>Could not load widget: ${msg}</p></div>`;
  }

  override renderMarkdownError(error: unknown) {
    const msg = error instanceof Error ? error.message : String(error);
    return `*Widget unavailable: ${msg}*`;
  }
}

Widget errors are fully contained — a failing widget never takes down the page.

Page component errors are not caught inline. When a page's getData() or render method throws, the error bubbles up to the next layer (error boundary or root handler).

Layer 2 — Error Boundaries (scoped)

An .error.ts file catches runtime errors for all routes under a URL prefix. Place it next to the routes it should protect:

routes/
  projects/
    [id].page.ts           →  /projects/:id
    [id]/
      tasks.page.ts        →  /projects/:id/tasks
    index.error.ts         →  catches errors for /projects/*

The boundary file exports a PageComponent:

// routes/projects/index.error.ts
import { PageComponent, escapeHtml } from '@emkodev/emroute';

class ProjectErrorBoundary extends PageComponent {
  override readonly name = 'project-error';

  override renderMarkdown() {
    return '# Project Error\n\nSomething went wrong loading this project.';
  }

  override renderHTML(args: this['RenderArgs']) {
    return `<mark-down>${escapeHtml(this.renderMarkdown(args))}</mark-down>`;
  }
}

export default new ProjectErrorBoundary();

Pattern matching: the file's directory determines the scope — an .error.ts file sitting in projects/ produces the pattern /projects. Any error thrown while rendering a route that starts with /projects (or /projects/...) is caught by this boundary. When multiple boundaries match, the most specific one (longest pattern) wins.

The name before .error.ts is ignored. Only the directory it lives in matters — index.error.ts, foo.error.ts, and [id].error.ts all behave identically in the same directory. Name it index.error.ts by convention (matching the root handler in Layer 3 below); naming it after a route segment, like [id].error.ts, reads as if it scopes specifically to that segment, but it doesn't — it scopes to the whole directory the same as any other name would.

Error boundaries are .ts only — they are components with rendering logic, not static content files.

Boundaries never see the error that triggered them. The component's getData() is called with an empty params: {} and a blank context (no URL, no route params) — there's no error field anywhere in DataArgs or RenderArgs. Verified live: two pages under the same boundary, one throwing new Error('...') and the other new TypeError('a completely different message'), render byte-identical boundary output. A single .error.ts cannot branch on what actually went wrong — it's a generic "something broke here" page, not a dispatcher. If you need different UI for different failure kinds (not unexpected bugs), that's what status pages are for below: throw a Response with a specific status and give each status its own page.

Caveat: a flat leaf page shares its boundary with its same-named directory

A flat page file and a directory of the same name resolve to the same internal route node — this is also what makes the context.isLeaf layout pattern work (docs.page.ts doubles as the layout for everything under docs/). The same sharing applies to [id].page.ts next to a [id]/ directory, and to plain static names — it isn't specific to brackets.

The problem: errorBoundary has no leaf/non-leaf split the way getData and renderHTML do via context.isLeaf. It's one value per node, full stop. So an index.error.ts meant only for the nested siblings also catches errors from the flat leaf itself:

routes/
  accounts/
    [id].page.ts            →  /accounts/:id           (detail view)
    [id]/
      edit.page.ts           →  /accounts/:id/edit       (mutation)
      delete.page.ts          →  /accounts/:id/delete     (mutation)
      index.error.ts          →  intended for edit/delete only

Verified live: an uncaught exception on /accounts/:id (the flat detail view — nothing nested involved) is caught by [id]/index.error.ts, the boundary meant for edit/delete. There is currently no way to give [id].page.ts an error boundary distinct from its nested siblings; they're forced to share. If this matters for your route, the only workaround is a uniquely-named directory (which changes the URL) — there's no manifest-level fix today.

Layer 3 — Root Error Handler (global fallback)

An index.error.ts at the routes root catches everything not caught by a scoped boundary:

routes/
  index.error.ts           →  catches all unhandled errors
  projects/
    index.error.ts         →  catches /projects/* errors first
// routes/index.error.ts
import { PageComponent, escapeHtml } from '@emkodev/emroute';

class RootError extends PageComponent {
  override readonly name = 'root-error';

  override renderMarkdown() {
    return '# Something Went Wrong\n\nPlease try again later.';
  }

  override renderHTML(args: this['RenderArgs']) {
    return `<mark-down>${escapeHtml(this.renderMarkdown(args))}</mark-down>`;
  }
}

export default new RootError();

If even the root error handler throws (or no root handler exists at all), the router falls back to a minimal inline message — verified live:

<h1>Error</h1>
<p>Path: /the/failing/path</p>
<p>{escaped message of the ORIGINAL error, not the boundary's own failure}</p>

This is the only place the raw exception message reaches the response body — every layer above it renders static, author-written content instead.

Status Pages (HTTP status codes)

Status pages are normal page files named by HTTP status code. They handle known HTTP conditions (not-found, unauthorized, forbidden) — as opposed to error boundaries which catch unexpected runtime failures.

routes/
  404.page.html            →  shown when no route matches
  401.page.ts              →  shown on 401 responses
  403.page.md              →  shown on 403 responses

Status pages support all three content types (.ts, .html, .md) and go through the standard PageComponent rendering pipeline.

Triggering a status page from a component: throw a Response object with the desired status code. The router catches it and renders the matching status page:

override async getData({ params }: this['DataArgs']) {
  const res = await fetch(`/api/projects/${params.id}`);
  if (!res.ok) throw new Response(null, { status: res.status });
  return res.json();
}

If no status page is defined for that code, the router renders a built-in fallback with the status message — but only for codes it recognizes (401, 403, 404, 500). Verified live: throwing new Response(null, { status: 503 }) with no 503.page.* defined falls back to a generic <h1>Error</h1> heading, not "Service Unavailable" or the number 503 — only the HTTP status code on the response is preserved, not a human-readable label. Define a status page for any code where a specific message actually matters.

Resolution Order

When something goes wrong during navigation or rendering:

Widgets:
1. Widget.renderError()           →  inline error, page keeps rendering

Pages:
1. Scoped error boundary          →  replaces page content for that URL prefix
2. Root error handler             →  replaces page content globally
3. Built-in inline fallback       →  <h1>Error</h1><p>Path: ...</p><p>{message}</p>

For HTTP status conditions (thrown Response objects):

1. Status page (e.g., 404.page.html)
2. Built-in inline status message

File Naming Summary

File patternRoleScope
index.error.tsRoot error handlerAll routes (global)
*.error.tsError boundaryRoutes under matched path
{code}.page.ts/html/mdStatus pageSpecific HTTP status code

Error boundaries and the root error handler are always .ts. Status pages support .ts, .html, and .md like regular pages.

Across Rendering Contexts

All three layers work identically in SPA, SSR HTML, and SSR Markdown modes:

  • SPA — errors caught during client-side navigation; error boundary or status page HTML injected into the <router-slot>
  • SSR HTML — errors caught during server-side rendering; the response includes the error/status page HTML with the appropriate HTTP status code
  • SSR Markdown — same as SSR HTML but components render via renderMarkdown() instead of renderHTML()

Next: Shadow DOM