Skip to content

Redirects

Leaving a page from inside the render, and what that costs.

Updated View as Markdown
src/app/products/[slug]/page.tsxtsx
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.

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.

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 and the client follows it:

src/actions.tsts
'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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close