`redirect()` never navigates and spins in an unbounded render loop on client-side navigation into a fully-dynamic route, with `cacheComponents` enabled
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
npm install && npm run build && npm start- Open
http://localhost:3000/ - Click the
/redirect-blockinglink. - 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. - Now navigate directly to
http://localhost:3000/redirect-blocking(address bar / hard refresh). Observe: it redirects to/targetcorrectly. - For contrast, click the
/redirect-suspenselink. 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-blocking — instant = 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:
- Don't call
reset()synchronously insideHandleRedirect's transition — no effect.reset()isn't the driver. - Latch
error.handled = trueingetDerivedStateFromError, mirroring whatserver-action-reducer.js:339already does for Server Actions — no effect. - Add a terminal state so the boundary renders
nullinstead 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 = falseon 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
cacheComponentsis the trigger. SettingcacheComponents: falsemakes all four routes redirect correctly on click, on the same Next version and the same page code. Note the one forced difference: theinstantexports must be removed too, because the build rejectsexport const instantwithout the flag ("Route segment config "instant" requiresnextConfig.cacheComponentsto be enabled"). That confound is unavoidable rather than incidental —instantexists only under Cache Components, so there is no configuration in which a route is fully dynamic viainstant = falsewithout 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 whereredirect()no longer works on client navigation. Nothing in the docs suggests that trade-off exists. - The
instantdocs describeinstant = falseas 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
redirectdocs 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 —
RedirectBoundaryreplaying stale redirects specifically undercacheComponents; hypothesizesRedirectBoundarylacking 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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