vercel / vercel/next.js

`redirect()` never navigates and spins in an unbounded render loop on client-side navigation into a fully-dynamic route, with `cacheComponents` enabled

Open
#97,898 2 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
142k
Forks
32.4k
Avg merge
2d 14h
Merged PRs (30d)
351

Description

Link to the code that reproduces this issue

https://github.com/backjo/nextjs-cachecomponents-redirect-repro

To Reproduce
  1. npm install && npm run build && npm start
  2. Open http://localhost:3000/
  3. Click the /redirect-blocking link.
  4. Observe: nothing happens. The URL stays at /, the page does not change, and there is no error in the console, in the terminal, or in the network panel. The tab is now pinned at ~100% CPU — open a profiler, or see the instrumented counts below.
  5. Now navigate directly to http://localhost:3000/redirect-blocking (address bar / hard refresh). Observe: it redirects to /target correctly.
  6. For contrast, click the /redirect-suspense link. That one redirects correctly on click.

The whole reproduction is four routes and no application logic:

// app/redirect-blocking/page.jsx   -> builds as ƒ (Dynamic)   -> BROKEN on click
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export const instant = false; // the documented "Block" escape hatch

export default async function RedirectBlocking() {
  const h = await headers();
  h.get("cookie");
  redirect("/target");
}
// app/redirect-suspense/page.jsx   -> builds as ◐ (PPR)       -> works on click
import { Suspense } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

async function Gate() {
  const h = await headers();
  h.get("cookie");
  redirect("/target");
}

export default function RedirectSuspense() {
  return (
    <Suspense fallback={<p>loading…</p>}>
      <Gate />
    </Suspense>
  );
}
// next.config.mjs
export default { cacheComponents: true };
Current vs. Expected behavior

Current: clicking a <Link> to a route that calls redirect() never navigates when that route builds as ƒ (Dynamic). The URL does not change and the page does not update. There is no error in the console, no server log, and no failed request — but the tab is now spinning in a hot render loop (see below), burning a CPU core for as long as the user stays on the page.

Expected: the client-side navigation follows the redirect and lands on /target, the same way a direct visit to the URL does.

The determining factor is the route's build classification

Every route below calls the identical headers() + redirect("/target"). The only thing that varies is what surrounds it, and the correlation with the build output is exact:

Route Build output Click a <Link> Direct visit
/redirect-blockinginstant = false, dynamic page ƒ ❌ stays put /target
/redirect-suspense — dynamic read inside <Suspense> /target /target
/blocking-layout/redirect-suspense — ancestor layout with instant = false but no runtime read /target /target
/dynamic-layout/redirect-suspense — page uses <Suspense>, but ancestor layout awaits headers() at top level ƒ ❌ stays put /target

ƒ breaks, works. Note the last row in particular: a page that correctly wraps its own dynamic access in <Suspense> is still broken if any ancestor layout reads a runtime API at its top level, because that pulls the whole route back to ƒ. That is the shape a typical auth layout has, which is how we hit this in a real app — an authenticated user clicking "Log in" (a route that bounces already-signed-in users to their dashboard) got nothing at all.

Root cause: the errored render never commits, so the redirect effect never runs

The redirect is not dropped, and the server is not at fault — it emits NEXT_REDIRECT correctly in both cases. RedirectBoundary even catches it correctly. The problem is that on an ƒ route the render that catches it can never commit, so the effect that would perform the navigation never fires.

Instrumenting client/components/redirect-boundary.js — counting RedirectErrorBoundary constructions, getDerivedStateFromError calls, and HandleRedirect's useEffect — then clicking each link once and waiting 5s:

Route boundary constructed getDerivedStateFromError HandleRedirect effect Result
/redirect-blocking (ƒ) 26,374 6,592 0 stuck on /
/redirect-suspense () 4 3 1 /target

router.replace() is never called even once on the broken route. HandleRedirect renders, but its effect requires a commit, and the commit never happens. (The measured facts are the three counts above; my reading of them is that React has no committable UI for this segment — no fallback to show — so it discards the render and retries indefinitely, but I have not verified that step inside React itself.) The loop does not converge:

t= 2s  catches=  2966  url=/
t=15s  catches= 22842  url=/

~1,500 iterations/second, unbounded, at ~100% CPU.

On a route the <Suspense> boundary gives React something committable. The tree commits, the effect runs once, router.replace() fires, and the navigation completes after 3 catches. That is the entire reason Suspense "works" — it makes the render committable, not because redirects are handled differently.

Fixes that do not work (tested, so you can skip them)

All three were applied to redirect-boundary.js, rebuilt, and re-measured. All three still loop unbounded:

  1. Don't call reset() synchronously inside HandleRedirect's transition — no effect. reset() isn't the driver.
  2. Latch error.handled = true in getDerivedStateFromError, mirroring what server-action-reducer.js:339 already does for Server Actions — no effect.
  3. Add a terminal state so the boundary renders null instead of re-rendering the throwing children — no effect, because the boundary is remounted from scratch each iteration (26k constructions) and loses the state.

The common reason all three fail: they operate on component state and effects, and on this path neither survives — nothing commits.

Suggested direction. The redirect needs handling in the router's data layer rather than in a render-phase error boundary. router-reducer/ppr-navigations.js already has precedent: fetchMissingDynamicData detects a hard redirect (fetchServerResponse returning a string href) and converts it into an MPA navigation, entirely outside render. A soft NEXT_REDIRECT arriving in the flight payload could be detected at the same layer and dispatched as a navigation, which would work regardless of whether the destination segment is committable. As it stands, redirect delivery depends on a commit that a fully-dynamic segment cannot produce.

The official authentication guide recommends exactly this combination

This is the part that makes it more than an edge case. authentication-with-cache-components.md (shipped in the package at node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md) opens by telling you to do precisely what breaks:

With Cache Components enabled, instant navigation validation flags every route that reads the session, because a request read can't be prerendered into the static shell. You don't have to resolve them all before shipping. Set export const instant = false on the page or layout to let it keep blocking on the server, then adopt the patterns below one route at a time.

And the central pattern in that same guide is a redirect:

// lib/auth.ts, from the guide
export async function getCurrentUser(): Promise<User> {
  'use cache: private'
  const { userId } = await getSession()
  if (!userId) {
    redirect('/login')
  }
  // ...
}

So the documented, sanctioned interim state for an app migrating to Cache Components — instant = false on an auth layout, redirect() for unauthenticated users — is exactly the state in which redirects stop working on client-side navigation. A team following the guide gets a silently broken auth bounce with no signal that anything is wrong.

The guide's end state (use cache: private + <Suspense>) produces a route and works correctly. It's only the recommended intermediate step that breaks, which is the worst place for it: an app is in that state precisely when it's least likely to have finished testing every route.

Additional context
  • cacheComponents is the trigger. Setting cacheComponents: false makes all four routes redirect correctly on click, on the same Next version and the same page code. Note the one forced difference: the instant exports must be removed too, because the build rejects export const instant without the flag ("Route segment config "instant" requires nextConfig.cacheComponents to be enabled"). That confound is unavoidable rather than incidental — instant exists only under Cache Components, so there is no configuration in which a route is fully dynamic via instant = false without the flag on. With the flag off, all four routes build as ƒ and all four work, which rules out "fully-dynamic route + redirect() + client navigation" being broken on its own.
  • The two documented escape hatches behave differently. The blocking-prerender-dynamic error offers Stream (<Suspense>), Cache ("use cache"), and Block (instant = false). Choosing Block produces a route where redirect() no longer works on client navigation. Nothing in the docs suggests that trade-off exists.
  • The instant docs describe instant = false as validation-only — "This opts the segment out of validation feedback… the framework just won't surface insights for it." But it also determines whether redirects survive a client navigation, which is a behavioral change, not a lint change.
  • The redirect docs say "When used in a streaming context, this will insert a meta tag to emit the redirect on the client side" — which covers the document response, but says nothing about the client navigation case.
  • It fails silently but not cheaply. No console error, no server log, no failed request, no devtools signal — yet the tab spins at ~1,500 boundary catches/second for as long as the user stays there. In a large app this is very hard to attribute: any route that gates on auth and redirects becomes a dead link that also cooks the user's battery.
Possibly related
  • #92767 — RedirectBoundary replaying stale redirects specifically under cacheComponents; hypothesizes RedirectBoundary lacking guards.
Which area(s) are affected? (Select all that apply)

cacheComponents, Linking and Navigating, Redirects

Which stage(s) are affected? (Select all that apply)

next build (local), next start (local)

Note: this does not reproduce under next dev — all four routes redirect
correctly on click there, with nothing logged. It only fails in a production
build, which is what makes it easy to ship without noticing.

Provide environment information
Operating System:
  Platform: darwin
  Arch: arm64
  Version: macOS 26.6.1
Binaries:
  Node: 26.3.1
  npm: 11.16.0
Relevant Packages:
  next: 16.3.3
  react: 19.2.0
  react-dom: 19.2.0
Next.js Config:
  cacheComponents: true
  output: N/A

Also reproduced on: 16.3.1, and 16.4.0-canary.6 (so it is not fixed on canary).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

First reproduce with the linked app using npm install && npm run build && npm start, then click /redirect-blocking and compare it with /redirect-suspense. Read client/components/redirect-boundary.js and the router-reducer/ppr-navigations.js handling for hard redirects, with server-action-reducer.js:339 as related precedent. Done means client-side navigation from / reaches /target without an unbounded render loop, while direct navigation remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, next.js, react
Domain
frontend, web-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.