Error pages
L5E ships one error path: any HttpException thrown from a middleware, a route handler,
a loader, or a component during render is caught by the framework and routed into the
_error view with { statusCode, message, data } props. You don't write a separate
404 view and a separate 500 view — you write one, branch on statusCode inside it.
The _error view
It's a regular view living at src/views/_error/index.tsx. Discovered automatically
by name, no registration.
// src/views/_error/index.tsx
import { Fragment, useCss } from '@withl5e/l5e/jsx-runtime';
import { MetadataRenderer } from '@withl5e/l5e/seo';
export interface ErrorPageProps {
statusCode: number;
message: string;
data?: Record<string, unknown>;
}
export default function ErrorPage({ statusCode, message, data }: ErrorPageProps) {
useCss('/src/views/_error/styles.css');
return (
<Fragment>
<main class="error-page">
<p class="error-page__code">{statusCode}</p>
<h1 class="error-page__title">{titleFor(statusCode)}</h1>
<p class="error-page__message">{message}</p>
{statusCode === 404 ? (
<a class="error-page__cta" href="/">Back to home</a>
) : (
<a class="error-page__cta" href="javascript:location.reload()">Try again</a>
)}
{process.env.NODE_ENV !== 'production' && data ? (
<pre class="error-page__data">{JSON.stringify(data, null, 2)}</pre>
) : null}
</main>
</Fragment>
);
}
function titleFor(code: number): string {
if (code === 404) return 'Page not found';
if (code === 500) return 'Something went wrong';
if (code >= 500) return 'Service unavailable';
if (code >= 400) return 'Bad request';
return 'Error';
}A few things to notice:
- It's a normal view —
useCss,useClientJs, islands, andMetadataRendererall work the same way as on any other view. - Props carry just enough to render: a status code, a human-readable message, and an
optional
databag. In production, hidedata— it can contain debugging info you don't want to expose. - One file handles every status code. Branch in the component, not in the routing.
What happens without _error
If you skip the file, the framework returns a plain <div>404 - Not Found</div> body
with the right status code. Functional, ugly. Add the view as soon as you ship.
Throwing exceptions
The framework exports a small set of HTTP exception classes from @withl5e/l5e. Each
just sets a statusCode and forwards the message + optional data payload.
import {
HttpException, // base class — extend for custom statuses
BadRequestException, // 400
NotFoundException, // 404
InternalServerErrorException, // 500
ServiceUnavailableException, // 503
RedirectException, // 30x (not routed to _error — emits Location header)
} from '@withl5e/l5e';Throw them wherever data validation fails:
// loader.ts
import { NotFoundException, ServiceUnavailableException } from '@withl5e/l5e';
export const loader: LoaderFunction = async ({ pathname }) => {
const slug = pathname?.split('/').pop();
if (!slug) throw new NotFoundException('Missing slug');
let article;
try {
article = await db.articles.findUnique({ where: { slug } });
} catch (err) {
throw new ServiceUnavailableException('Database unavailable', {
cause: String(err),
});
}
if (!article) throw new NotFoundException(`Article ${slug} not found`);
return { props: { article } };
};You can attach a data object for the error view to surface in development:
throw new NotFoundException('Article not found', {
slug,
attemptedAt: new Date().toISOString(),
});The data flows into ErrorPageProps.data — gate it behind NODE_ENV !== 'production'
when you render so it never leaks to public visitors.
How L5E decides to render _error
Three doors lead to the error view:
route.tsreturnsnull. The framework treats it as a 404 and renders_errorwithstatusCode: 404. Useful for unknown paths without explicit throws.- An exception bubbles up. Anything that extends
HttpException(or any otherError— those become 500) thrown from middleware, the route handler, a loader, or the component during render. - A
RedirectExceptionis thrown. This one doesn't render_error. The framework converts it into a 30x response with aLocationheader and never reaches the error view.
A loose call graph:
Request
├── middleware throws? ─────→ catch → render _error with status
├── route.ts returns null? ─→ catch → render _error with status=404
├── route.ts throws? ───────→ catch → render _error
├── loader throws? ─────────→ catch → render _error
└── component throws? ──────→ catch → render _error
(RedirectException at any layer ─→ 30x + Location header)Cache headers on errors
The _error view is rendered without going through a loader, so it has no opportunity
to set cache directives. The framework reflects that by emitting no Cache-Control
header on error responses — neither at the public nor private level. CDNs that respect
the missing header (most do) won't cache the page.
The baseline Cache-Tag: global header is still emitted (so a global purge invalidates
the error page along with everything else), but without Cache-Control the tag is
typically a no-op.
Practical consequences:
- 5xx responses never accumulate at the CDN — useful, because a transient outage
would otherwise poison the cache for the duration of
s-maxage. - 404 responses don't cache either. For most sites that's fine. If you have a
high-traffic 404 surface and want to absorb the load at the CDN, the only honest path
is a
rawResponsewithstatusCode: 404and explicit cache headers from a regular view's loader — not the_errorview.
Common pitfalls
- Don't catch and re-throw exceptions. Let
HttpExceptionbubble — the framework's catcher is the one routing it. Atry { … } catch { return null }in your loader hides the real status code and breaks the error view. - Don't render
_errorfrom your own code. Throw the exception instead. The framework owns the rendering path. - Don't put expensive work in
_error. The view runs for every 404 a crawler triggers. Keep it template-only — no DB calls, no API hits. - Don't redirect from
_error. Returning a 30x from inside the error view leaves the response with the original error status. If you need a redirect, throwRedirectExceptionfrom the layer that detected the problem (route or loader).