stoneware

Documentation

Error pages

Custom 404 and 500 pages, and the three properties that hold whether or not you write them.

Add routes/_404.tsx or routes/_500.tsx and it replaces the built-in page. There is no registration step and no config key — the file existing is the whole API.

routes/_404.tsxtsx
import type { ErrorPageProps } from "stoneware";
import { Layout } from "../lib/Layout.tsx";

export default function NotFound({ url }: ErrorPageProps) {
  return (
    <Layout title="Not found">
      <h1>No page at {url.pathname}</h1>
    </Layout>
  );
}

These are ordinary templates rendered through the ordinary pipeline: your layout, your stylesheet, your islands. A 404 is a page real visitors reach, and it should not be the only one on the site that looks unfinished.

What _500 receives

Both pages get status, message, request and url. _500 also gets error — the thrown value — populated in development only. In production it is undefined.

That is decided by the framework rather than left to each error page to handle responsibly. An exception message routinely carries a file path, a query, or a connection string, and the page that renders it is the one page guaranteed to be shown when something has already gone wrong.

When the route matched but the content does not exist

_404.tsx only fires when nothing matched. A [slug] route matches any slug, so it reaches your template and then discovers there is no such post. Rendering not-found markup there would serve it with a 200 — a soft 404, which search engines index and which tells a client the request succeeded.

routes/blog/[slug].tsxtsx
import { notFound } from "stoneware";

export default function Post({ params }: PageProps) {
  const post = getPost(params.slug);
  if (!post) notFound();

  return <article>{post.title}</article>;
}

It renders your _404 page with a 404 status. Because it throws rather than returns, it works from a helper several calls deep without every function in between having to pass it back up — and it returns never, so TypeScript narrows post as present afterwards with no non-null assertion.

An underscore means it is not a page

A route file whose name starts with _ is a convention, not something servable. Requesting /_404 does not return it with a 200 — it returns the 404 page, with a 404 status, like any other path that does not exist.

Three properties you get either way

  • Failure is terminal. If your _500.tsx throws, the built-in page is served — the error path never re-enters itself.
  • Errors are never cached. Cache-Control: no-store, so a 404 held by a CDN cannot outlive the deploy that adds the page.
  • Security headers still apply. Error responses leave through the same single exit as every other response.

The first one is the reason error rendering is separate from page rendering rather than reusing it. Everywhere else, a thrown error escalates to the 500 page; here there is nowhere left to escalate to, so failure has to stop.

stoneware export writes the 404 page to dist/404.html — the file Cloudflare Pages, Netlify and GitHub Pages each serve for an unmatched path. A static export gets your error page too, not the host's default one.

An error page is the response when a page could not be rendered at all. For the narrower case — one widget failed and the article around it is fine — see error boundaries.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.