Two files, both optional, both following the same rule as loading.tsx: the
nearest one wins.
When a page throws
With nothing of your own, a page that throws shows the engine’s error page where the page was, and the layouts around it stay: in development the message and stack, in production “Something went wrong” with the digest to search the server log for, and a Try again in both. It used to show nothing — React unmounted the document on hydration, a black page with the cause nowhere near it.
That page is the fallback. To show something of your own:
Put an error.tsx in the directory you want to cover:
'use client'
import type { RouteErrorProps } from '@rsc-kit/core/RouteErrorBoundary'
export default function OrdersError({ error, reset }: RouteErrorProps) {
return (
<section>
<h1>That did not work</h1>
<p>{error.message}</p>
<button type="button" onClick={reset}>Try again</button>
</section>
)
}Anything below it that throws renders this instead. The layouts above stay on screen — only the segment that failed is replaced.
reset() asks the server for the page again and renders what it answers, for
a failure that might not happen twice - a service that was down for a moment.
The layouts stay, and the error stays on screen until the new page is in hand.
If the page fails again, the error is shown again. It is the whole retry: do
not call refresh() beside it or reload the document. Navigating away clears
the error on its own.
What error.message says in production
React replaces it with a generic sentence and puts a hash on error.digest.
That is React, not this package: the real message could name a table, a query or
a path, and none of that should reach a browser.
Log the digest where you log the error, and the two line up.
It does not catch everything
- Errors in the layout above it. The boundary sits inside that layout, so a
layout that throws needs an
error.tsxa directory up. The engine’s own page is outermost and catches those too. - The build. A page that throws every time it renders fails the build rather than shipping a stored error page. The boundary is for a request that goes wrong, not a page that is broken.
Telling a refusal apart in a server component
A failed rpc() throws the engine’s own error for a refusal, so a server
component can answer it rather than fail:
import { ServerAuthenticationError } from '@rsc-kit/core/errors'
async function Invite({ token }: { token: string }) {
try {
return <Accept invite={await rpc('Invites.find', token)} />
} catch (error) {
if (error instanceof ServerAuthenticationError) return <SignInToAccept />
throw error
}
}ServerAuthenticationError is a 401 from the backend, ServerAuthorizationError
a 403. A refusal the backend gave a status of its own (abort(429), Go’s
Refuse(503, …)) carries it as error.refusalStatus beside the backend’s own
message, and uncaught the page answers that status. Rethrow what you do not
handle, so a not-found or a redirect still does what it should.
A server module in the browser bundle
[rsc-kit] '@rsc-kit/core/redirect' is for the server, and ended up in the browser bundle:
imported by src/lib/team.ts
which is imported by src/app/teams/controls.tsxThe build stops, and in dev the module fails to load, when a file the browser
imports reaches request, redirect, revalidate, cache, section, or
anything else of this package that only makes sense on a server. The usual
cause is a helper file that two kinds of code share: a page calls its
redirect(), and a client component imports its label(). The import loads,
nothing in the browser calls it, and the only trace used to be Vite saying a
Node builtin had been “externalized for browser compatibility” — while the
server’s request-scope code shipped to every visitor.
Split the file. What needs the server module goes in one only server components import; what a client component needs goes in the other. A type-only import is erased and never counts.
Mark your own server-only files the same way, with import 'server-only'.
The build honours it the same way: a client file that reaches it fails with
the files that brought it there. (This package cannot put that import in its own
modules, because the real package throws under any runtime that is not a
server-components build — a unit test of anything calling cookies() included.)
When nothing answers the url
src/app/not-found.tsx is rendered for any url no route matches — and a
client-side navigation to one renders it in place, layout kept, url changed,
status still 404, the way Next.js does once the page is overridden. Without
the file, a navigation to a missing url is a full document load of a plain
“Not found”:
import Link from '@rsc-kit/core/Link'
export default function NotFound() {
return (
<main>
<h1>No such page</h1>
<p>Nothing answers that url. <Link href="/">Go home</Link>.</p>
</main>
)
}It renders through your root layout like any other page, and is served with a
real 404 — a page that says “not found” under a 200 is a page search
engines index.
An ordinary server component, so it can be async and read whatever it likes,
the visitor’s cookies included. Without one, an unmatched url gets a plain
Not found string.
Only a request that can show a page gets it: a navigation, or one that accepts
HTML. A missing image, script or stylesheet — a browser’s /favicon.ico — is
answered with a plain 404 at once, without rendering your layouts for it.
The same holds under a dynamic route. /[team] claims nearly every path, but
a page never answers an image, a script, a stylesheet or a font: a request the
browser marks as one (Sec-Fetch-Dest) gets a plain 404 before any of the
route’s middleware runs. A route.ts still answers such a request — an image
endpoint is a route, not a page — and a navigation, a fetch(), or a client
that does not say, gets the page as always.
A section’s own 404
A not-found.tsx beside a layout answers a notFound() from every page under
it, inside that layout, the way an error.tsx catches what fails under it:
src/app/
layout.tsx
not-found.tsx ← an unmatched url, and anything outside the shop
(shop)/
layout.tsx ← the shop's header and cart
not-found.tsx ← a missing product: the header stays
c/[category]/[sub]/[product]/page.tsxThe nearest one above the page that said so wins. Called from a page’s body, a
Suspense-wrapped read or a middleware.ts, it is the same file, and the
layouts above it stay on screen; the ones between it and the page are replaced
with it, since it stands in for them. A url no route owns has no page to be
under, so only the root not-found.tsx answers it: a section’s does not leak
out to the whole site.
When the page has to decide
A url can match a route and still name nothing — /posts/42 is a real route
and there may be no post 42. Only the page can know that, so the page says so:
import { notFound } from '@rsc-kit/core/not-found'
export default async function PostPage({ params }) {
const post = await findPost((await params).slug)
if (!post) notFound()
return <article>{post.title}</article>
}Same page, same 404. It throws, so nothing after it runs — and if you wrap
the call in a try/catch, rethrow what you do not recognise, or a missing
page becomes a blank region.