UNPKG

@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.

220 lines (166 loc) • 8.78 kB
# Redirects > Leaving a page from inside the render, and what that costs. ```tsx title="src/app/products/[slug]/page.tsx" import { redirect } from '@rsc-kit/core/redirect'; import { findProduct } from '../../../data'; export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const product = await findProduct(slug); if (!product) redirect('/products'); return <h1>{product.name}</h1>; } ``` `redirect` never returns — it throws, which is what stops the component. The default status is `307`, because it preserves the method: a redirect out of a `POST` does not silently become a `GET` of the target. A redirect during a navigation stays a navigation. The document does not reload, the layouts you are inside stay mounted, and only the part below them changes. The url that redirected replaces its history entry rather than adding one, so Back does not land on it and redirect you again. ## With a query string `search` is typed to the destination page's own `searchParams` schema, the same check `Link` puts on its `search` prop — a key the page never reads, or a number written as text, does not compile: ```ts redirect('/', { search: { auth: true } }); // → /?auth=true redirect('/search', { search: { q, page: 2 } }); // checked against /search's schema redirect('/old', { status: 308 }); // a permanent one ``` A bare second argument is still the status: `redirect('/old', 308)`. ## Where you call it matters This is the part worth understanding, because it is also the security-relevant part. Headers flush early on purpose — that is what makes the first paint fast — so a redirect decided late has no status line left to use. There are two windows, and nothing you write chooses between them: | Called | Answered with | What the browser saw first | | --- | --- | --- | | Above every `<Suspense>` boundary | A real `3xx`, or `X-RSC-Redirect` on a navigation | Nothing at all | | Inside a boundary | The shell, then the redirect | Layouts, and the fallbacks standing in for what never arrived | Neither buffers the response. Before anything is written the host is still waiting on the shell, so a component that redirects instead of rendering is caught there. After that, React already carries an error digest to the client and the destination rides along in it, and the browser performs it as a navigation — the layouts above stay mounted, the url being left is replaced rather than kept in history. An `error.tsx` on the route never sees it: a redirect is the page's answer, not a failure, whether it is thrown by the page, by a component under its own `<Suspense>`, or by a parallel route slot. <Aside type="caution" title="Middleware belongs above the boundaries"> A `loading.tsx` wraps the whole page in `<Suspense>`. That is usually what you want — it is how a page gets a frozen shell — but it also means an `await` at the top of that page is *inside* a boundary, and a redirect after it arrives in the second window. Nothing from inside the boundary was shown, so a not-found redirect is fine there. An authorization check is different: the layouts above it already rendered and already went out. If a page must reveal nothing at all, the check has to run somewhere that blocks the shell. </Aside> ## Where to put an authorization check In order of preference: **In `middleware.ts`.** A file beside the layout, run before anything at or below it renders, on every path. This is the one built for the job. <Aside type="danger" title="A layout is not a security boundary"> It is tempting — a layout renders above the page's boundary, so on a full page load a redirect there happens before anything is sent. But a navigation tells the server which layouts the client already has, in `X-RSC-Segments`, and the server skips re-rendering those. A client that *claims* to hold your layout skips the check: ```bash curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout' /guarded # 204, X-RSC-Redirect: /orders ← the middleware ran curl -H 'X-RSC: 1' -H 'X-RSC-Segments: app/layout,app/guarded/layout' /guarded # 200, and the page's content ← it did not ``` The header is not verified and cannot be. Put the check in a `middleware.ts` beside the layout instead — it runs before anything below it renders, on every path. See [Authorization](/guides/authorization). </Aside> **At the top of a page with no `loading.tsx`.** Runs on every render of that page, so it is not skippable the way a layout is — but fragile in a different way: adding a `loading.tsx` later silently moves the check into the second window, and nothing warns. <Aside type="danger" title="Never catch it and continue"> `redirect` communicates by throwing. A `try`/`catch` that swallows everything turns the redirect into a blank region — the component stops, and nothing takes its place. If you must wrap the call, rethrow what you do not recognise: ```ts import { isRedirectSignal } from '@rsc-kit/core/redirect'; try { await mightRedirect(); } catch (error) { if (isRedirectSignal(error)) throw error; // … } ``` </Aside> ## From a server action An action is not a render, so there is no shell to be on either side of. Throw from the action — a plain `"use server"` function or one built from `createActionClient()` — and the client follows it as a navigation: the layouts stay mounted and the history entry the form was on is replaced, so Back does not return to the submitted form. A `<Form>` shows nothing for it; signing in and arriving is the success. To the caller, the action **resolves** — with `{ redirected: '/where' }` — once the navigation is under way. It does not throw: a `startTransition(async () => { await logOut() })` with no `catch` around it would otherwise reject into React, which unmounts the root, and a logout ended on a white page. There is nothing to catch; a component that awaited the action is on its way off the screen. What it got is not the action's result, though, and only an action built on `createActionClient()` says so in its type: its result is always an object with `redirected` among the fields. A stub the build generated for a backend action is typed `Promise<T | Redirected>`, so reading the value without narrowing is a type error, and `isRedirected` is the narrowing: ```ts import { isRedirected } from '@rsc-kit/core/errors' const secret = await databasesReveal(id) if (isRedirected(secret)) return // on its way to /login copy(secret.Password) ``` `tsc` does not see the value read as text. A template literal accepts any type, so `` visit(`/t/${id}`) `` on a `string | Redirected` compiles and navigates to `/t/[object Object]`. Three type-aware lint rules do see it: `typescript/restrict-template-expressions`, `typescript/no-base-to-string` and `typescript/restrict-plus-operands`. A project from `create-rsc-kit` with `--lint` has them on. In one that does not: ```sh npm i -D oxlint-tsgolint ``` ```json title=".oxlintrc.json" { "rules": { "typescript/restrict-template-expressions": "error", "typescript/no-base-to-string": "error", "typescript/restrict-plus-operands": "error" } } ``` and run `oxlint src --type-aware`. Type-aware rules need the flag. An action that returns nothing needs the same check, because `await stub(); toast.success('Saved')` runs after the redirect has started and reports a write that never happened. Nothing checks this one: no type and no stock lint rule flags a result that is awaited and thrown away, so it is a matter for review and for a [test](/guides/testing#components) that clicks it. A plain `"use server"` function you wrote is typed as what it returns, which cannot say this; build it on `createActionClient()`, or check `isRedirected` on its result. ```ts title="src/actions.ts" 'use server' import { redirect } from '@rsc-kit/core/redirect'; export async function createPost(title: string) { const post = await savePost(title); redirect(`/posts/${post.slug}`); } ``` ## A route that only redirects The redirect itself is stored, so it costs no render at all: ```text ○ /old-pricing (redirects to /pricing) ``` The build writes the status and the location, and the host answers from that file — a document gets the status code, a navigation gets `X-RSC-Redirect` and does it as an SPA navigation. Nothing is rendered per request, and a static export can carry it. ## Loops A navigation follows at most **8** redirects before throwing. A page that redirects to itself is a mistake someone will make, and without a ceiling it is an unbounded run of full renders rather than an error you can see.