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 form posted before the page has javascript - or by a browser with none - is
the exception: a redirect out of it is answered 303 See Other, whatever
status it was given. The browser follows it with a GET, as it should after a
form. A 307 would post the form again at the destination, and run the action
again there.
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:
redirect('/', { search: { auth: true } }); // → /?auth=true
redirect('/search', { search: { q, page: 2 } }); // checked against /search's schema
redirect('/old', { status: 308 }); // a permanent oneA bare second argument is still the status: redirect('/old', 308).
Leaving the app
A destination does not have to be one of this app’s pages. Two kinds are not, and a redirect to either is a full page load rather than a navigation — the router does not fetch them as pages, because neither answers one:
// Another site. Any http(s) url; it type-checks as written.
redirect('https://gitlab.com/oauth/authorize?client_id=…');
// A url your backend owns, named in backendPaths (below). The build types the
// prefix, so this needs no cast either.
redirect('/gitlab/connect');It works the same wherever the redirect is called, above a <Suspense>
boundary or inside one, and on a first visit as on a click. In every case the
url that redirected is replaced in history, so Back from the other site lands
on the page before it rather than on one that sends you out again.
The second kind needs its prefix named, because the app cannot otherwise tell the backend’s url from a page it has not built yet:
rscKit({ backendPaths: ['/auth', '/github', '/gitlab'] })A name in backendPaths is also what keeps the router from asking for that url
as a page. Without it, the router fetches /gitlab/connect for its payload, the
backend answers with a redirect to another site (an OAuth start does), and the
browser refuses that hop — a fetch cannot follow a redirect to another origin
with the headers the router sends. The click then does nothing: the old page
stays and the url does not move. Named, a Link, visit() or redirect() to
it loads the document, and it is never prefetched — a hover cannot start the
flow. backendPaths is described with the backend.
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.
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.
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.
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.
The layouts are not rendered again, as on any navigation: most redirects —
save, then the list — change nothing a layout reads. One that does says so,
the way any action does. revalidate('all') before the redirect() asks for
the destination whole, layouts included; a named section counts too, since it
can live in a layout. A team switch whose name is in the sidebar:
'use server'
export async function switchTeam(id: string) {
await setCurrentTeam(id)
revalidate('all')
redirect('/dashboard')
}Without it the page is the new team’s and the sidebar still names the old one
until a reload. The same holds for a backend action: Rsc::revalidate('all')
before the redirect. 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:
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:
npm i -D oxlint-tsgolint{
"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 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.
'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:
○ /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.