@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
228 lines (176 loc) • 9.37 kB
Markdown
# Errors and 404s
> What a visitor sees when a page throws, or asks for a url nothing answers.
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:
```tsx title="src/app/orders/error.tsx"
'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()` renders the segment again, for a failure that might not happen twice.
Navigating away clears it on its own.
:::caution[It has to be a client component]
`'use client'` at the top, or the build refuses it. Catching a render error is
a class component's job, it runs in the browser, and `reset` is a callback the
browser calls — none of which a server component can do.
:::
### 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:
```tsx
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
```txt
[rsc-kit] '-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":
```tsx title="src/app/not-found.tsx"
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:
```tsx title="src/app/posts/[slug]/page.tsx"
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.
:::caution[Where you call it decides the status]
Called above every Suspense boundary — at the top of a page, in a layout —
nothing has been written yet, so the response is a real 404.
Called deeper, inside a boundary, the shell has already gone out with the
status line on it. The status stays `200` — for a person — and the browser
shows your `not-found.tsx` where the page was: it asks the server for it, the
layouts above `not-found.tsx` stay, and the url is the one that was asked for.
It never reaches an `error.tsx`, and nothing in your code checks for it.
The page ends with `<meta name="robots" content="noindex">`, so a search
engine that reads it does not keep it as a real page.
A search engine or a link-preview scraper is never sent the shell. It is
answered once the whole page has rendered, so its `notFound()` is a real 404
wherever it was called, a `redirect()` a real redirect, and the metadata is in
`<head>`. The engine recognises them by user agent: Googlebot, Bingbot,
Applebot, DuckDuckBot, Yandex, Baidu, and the preview fetchers of Facebook, X,
LinkedIn, Slack, Discord, WhatsApp, Telegram and others. People never wait for
this.
A `loading.tsx` is a boundary around its page, so a page under one is always
"deeper". When the answer depends on the request — who is asking, a record
their account can see — make the check in a `middleware.ts` beside the page.
It runs before anything is sent, on every path, including a page the build
stored, and its `notFound()` is a real 404. Its argument is the route's
params, as a plain object: `{ slug: '42' }`.
:::