@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.
153 lines (114 loc) • 5.5 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. Rethrow what you do not handle, so a not-found or a redirect still does
what it should.
## 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.
Without one, an unmatched url gets a plain `Not found` string.
### 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 boundary shows its fallback instead and the status stays
`200`. If the status matters — and for anything a crawler or a cache sees, it
does — do the lookup above the boundary.
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' }`.
:::