Skip to content

Errors and 404s

What a visitor sees when a page throws, or asks for a url nothing answers.

Updated View as Markdown

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:

src/app/orders/error.tsxtsx
'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.tsx a 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.tsx

The 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”:

src/app/not-found.tsxtsx
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.tsx

The 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:

src/app/posts/[slug]/page.tsxtsx
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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close