GitHub

Error Pages

A spec with route: '*' is your site's custom 404 page. It renders through the normal pipeline — layout, styles, components, hydration — with status 404 whenever no route matches. Without one, visitors to a bad URL get the framework's unbranded default.

The custom 404 page

Create a spec like any other page, with '*' as the route. Use the shared layout() so the page carries the site's nav and footer:

src/pages/not-found.js
import { layout } from '../components/layout.js'

export default {
  route: '*',
  meta: {
    title:  'Page not found',
    styles: ['/pulse-ui.css', '/theme.css', '/app.css'],
  },
  view: () => layout({
    content: `
      <h1>Page not found</h1>
      <p>That page doesn't exist. <a href="/">Back home</a></p>
    `,
  }),
}

The filename doesn't matter — not-found.js is just a readable convention. The route: '*' property is what registers it as the fallback.

Every site should have one. The framework's default 404 is a bare unstyled page with no navigation — a dead end for visitors and a missed signal for search engines crawling old URLs.

How it behaves

AspectBehaviour
Status codeAlways 404 — correct for SEO; crawlers de-index dead URLs
PipelineFull normal render: meta, styles, components, hydration if it has mutations/actions
ValidationValidated at startup like every spec — a broken 404 page fails fast
MatchingOnly when no other route matches. It is a fallback, not a route — it never shadows real pages
Client-side navigationA bad link falls back to a full page load, which renders the custom 404
Caching404 responses are never stored in the in-process page cache

Making the 404 useful

The best 404 pages help the visitor recover. Since the page is a normal spec, it can use any component — search, popular links, even server data:

export default {
  route: '*',
  meta:  { title: 'Page not found', styles: ['/pulse-ui.css', '/theme.css', '/app.css'] },
  server: {
    popular: async () => getPopularPages(),   // suggest somewhere to go
  },
  view: (state, server) => layout({
    content: `
      <h1>Page not found</h1>
      <p>Try one of these instead:</p>
      ${list({ items: (server.popular ?? []).map(p => `<a href="${p.href}">${p.title}</a>`) })}
    `,
  }),
}

500s and other errors

Server errors are handled by createServer's onError option, not a spec — when a render throws, the spec pipeline itself may be the thing that is broken, so the handler works at the HTTP level:

await createServer(pages, {
  onError: (err, req, res) => {
    console.error(err)
    if (res.headersSent) return
    res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' })
    res.end('<h1>Something went wrong</h1><p><a href="/">Back home</a></p>')
  },
})

For errors thrown inside a view specifically, prefer onViewError on the spec — it returns a 200 with fallback HTML instead of a 500, keeping the page alive when one piece of data is malformed.

Three layers, three jobs: route: '*' for URLs that don't exist, onViewError for views that throw on bad data, onError for everything else that goes wrong server-side.

The error journal — a fourth layer, for the agent

The three layers above all handle an error for the visitor — they decide what the browser sees. None of them tell anyone the error happened. In dev mode (dev: true), every error caught by those layers — plus post-hydration client failures the browser never sends to the server on its own — is also recorded to .pulse/errors.json, a small rolling log an agent can query instead of relying on a console.error line landing in a terminal no one's watching.

// .pulse/errors.json — written automatically in dev mode
[
  {
    "id":       "8f32da67-…",
    "ts":       "2026-09-19T13:25:06.416Z",
    "route":    "/dashboard",
    "phase":    "view",        // 'view' | 'action' | 'server' | 'guard'
    "message":  "Cannot read properties of undefined (reading 'name')",
    "stack":    "TypeError: …",
    "resolved": false
  }
]

This isn't something you write to directly — the framework captures it. Two MCP tools read and manage it: pulse_diagnose (optionally filtered to one route) surfaces unresolved entries; pulse_resolve_error clears them once the underlying issue is fixed. /verify checks the journal for the route it's verifying before writing the .pulse-verified stamp — an unresolved error blocks the stamp, and a clean pass auto-resolves the route's entries. The same two checks are reachable with no agent involved — pulse diagnose and pulse resolve-error from a terminal share this exact logic.

The journal is a dev-only, build/iterate-time tool — capped at the most recent 50 entries, not a production error tracker. Nothing is written when dev is false, and the client-side reporting endpoint (/__pulse/error) doesn't exist outside dev mode either.

Reference

PropertyWhereNotes
route: '*'specCustom not-found page — rendered with status 404 when no route matches
onViewErrorspecFallback HTML when the view throws — returns 200
onErrorcreateServer optionHTTP-level handler for unhandled server errors (500s)
.pulse/errors.jsondev mode onlyRolling error journal — read with pulse_diagnose, cleared with pulse_resolve_error