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:
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.
How it behaves
| Aspect | Behaviour |
|---|---|
| Status code | Always 404 — correct for SEO; crawlers de-index dead URLs |
| Pipeline | Full normal render: meta, styles, components, hydration if it has mutations/actions |
| Validation | Validated at startup like every spec — a broken 404 page fails fast |
| Matching | Only when no other route matches. It is a fallback, not a route — it never shadows real pages |
| Client-side navigation | A bad link falls back to a full page load, which renders the custom 404 |
| Caching | 404 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.
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.
dev is false, and the client-side reporting endpoint (/__pulse/error) doesn't exist outside dev mode either.Reference
| Property | Where | Notes |
|---|---|---|
route: '*' | spec | Custom not-found page — rendered with status 404 when no route matches |
onViewError | spec | Fallback HTML when the view throws — returns 200 |
onError | createServer option | HTTP-level handler for unhandled server errors (500s) |
.pulse/errors.json | dev mode only | Rolling error journal — read with pulse_diagnose, cleared with pulse_resolve_error |