← All articles
Next.js Page Transition Breaks With a Redirect: The Fix
When a Next.js page transition breaks with a redirect, the App Router commits the redirecting route first — an empty stub. Here's the cause and the real fix.

One link in a client site's menu broke my custom SPA page transition twice. The first time, clicking it faded the overlay in, ran router.push(), faded the overlay out — and revealed a completely blank page. I patched the animation, shipped, moved on. A week later the same link came back wearing a different mask: a white flash and a violent layout shift on arrival. Same link. Same root cause. The reason a Next.js page transition breaks with a redirect is that the App Router commits the redirecting route first — and the durable fix was deleting the redirect, not patching the animation.
Here's the full autopsy, the numbers that proved it, and the three fixes in the order that actually matters.
The setup (a normal custom SPA transition)
Nothing exotic — this is the shape most creative sites use:
- A full-screen overlay fades in (cover).
router.push()fires.- The overlay fades out (reveal).
Reveals are gated on a small zustand store that walks a page through three states — LEAVE → PLAY → ENTER. Every animated component subscribes in a useEffect to a one-shot pagePlay event and runs its intro when the event fires. Until then, elements sit at their mount-time visibility: hidden. The overlay does double duty: it also hides the smooth-scroll reset that has to happen between routes.
That store is the load-bearing wall of the whole system. It works flawlessly — right up until a route lies about where it goes.
The symptom, and the tell
The broken link pointed at a section index route — a page whose only job was to redirect() on the server to its first child slug (/guides → /guides/getting-started).
The symptom:
- Click the link in-app → destination renders blank. Header and footer present, content invisible.
- Load that exact same final URL directly in a fresh tab → perfect. Every reveal plays.
That asymmetry is the entire tell. A bug that only appears via in-app navigation and never on a direct load is never a CSS bug and rarely an animation bug — it's a lifecycle bug. Something about the navigation itself is different, and you should go measure the navigation, not the animation. (I learned the same lesson the hard way when a WebGL hero disappeared on browser back — different API, identical class of mistake.)
Why a Next.js page transition breaks with a redirect
Here's the headline, and you can verify it yourself in ten minutes:
On a client-side navigation, the Next.js App Router commits the redirecting route first — an empty stub page — and only then navigates on to the real destination.
It is not one navigation. It is two. The redirecting route is a real, committed, rendered page in between: it has your layout (header, footer), and no content. Then a second navigation replaces it with the actual destination.
I proved this by sampling the DOM once per animation frame across the whole navigation (recipe below). The interesting column was document.body.scrollHeight:
| Frame | location.pathname | body.scrollHeight |
|---|---|---|
| … | / (origin page) | 4212 |
| n | /guides | 517 ← the stub: header + footer, no content |
| n + ~1.7s (dev) | /guides/getting-started | 4699 ← the real page |
517 pixels of page between two full-height pages. That's a layout with nothing in it. Once you see that row, the whole thing collapses into a single explanation — and that one extra hop breaks two completely independent things.
Breakage 1 — the blank destination
The one-shot pagePlay event fires for the stub. The real page's reveal components mount after it. Their useEffect subscription only catches future store transitions, so they miss the event forever, and every element stays pinned at its mount-time visibility: hidden.
The animation was never broken. It fired on time, at a page that had nothing to animate.
Breakage 2 — the white flash and the layout shift
The overlay's reveal was gated on an "assets loaded" counter that, when I actually read it, was really just a ~100ms timer wearing a serious-sounding name. So the overlay lifted while the destination was still arriving — revealing the empty stub, and then the real content popped in underneath the user's eyes. That's the flash and the shift, and it's exactly the kind of thing that wrecks the CLS budget on an animation-heavy site.
Worse: the redirect hop's effect cleanup cancelled a pending unregister call, so the counter could stick below 100% — meaning the overlay could never fade. A permanent white page with no way out.
The debugging recipe: sample the navigation, don't guess at it
This is the part worth stealing regardless of your stack. Stop eyeballing animation timings and start diffing frames. Paste this in the console, click the link, then read the table:
const rows: Record<string, unknown>[] = [];
let raf = 0;
const sample = () => {
const overlay = document.querySelector<HTMLElement>('[data-transition-overlay]');
rows.push({
t: Math.round(performance.now()),
path: location.pathname,
overlay: overlay ? getComputedStyle(overlay).opacity : 'n/a',
height: document.body.scrollHeight,
});
raf = requestAnimationFrame(sample);
};
sample();
// ...now click the link. When it settles:
// cancelAnimationFrame(raf); console.table(rows);
Three columns, one row per frame. An empty-stub hop shows up instantly as a height of a few hundred pixels sandwiched between two full-height pages, and you get the exact frame where the overlay opacity crossed zero relative to the path change. Numbers beat guessing at easing curves.
Fix 1 — delete the hop (this is the real fix)
A route that server-redirect()s must not be a client-nav target. Don't patch the links — the nav, the hero CTA, the footer, and a breadcrumb all pointed at that same index URL. Patching them link-by-link is whack-a-mole, and the next person to add a link re-opens the bug.
Fix the route. Render the destination inline at that URL instead of bouncing:
// app/guides/page.tsx — BEFORE: a client-nav target that redirects.
import { redirect } from 'next/navigation';
import { getFirstGuideSlug } from '@/lib/guides';
export default async function GuidesIndex() {
redirect(`/guides/${await getFirstGuideSlug()}`); // ← the extra hop
}
// app/guides/page.tsx — AFTER: render the destination INLINE at this URL.
import type { Metadata } from 'next';
import { getFirstGuideSlug, getGuide } from '@/lib/guides';
import { GuideView } from '@/modules/guides/GuideView';
export async function generateMetadata(): Promise<Metadata> {
const slug = await getFirstGuideSlug();
// Point canonical at the real slug so the two URLs aren't duplicate content.
return { alternates: { canonical: `/guides/${slug}` } };
}
export default async function GuidesIndex() {
const slug = await getFirstGuideSlug();
return <GuideView guide={await getGuide(slug)} />;
}
app/guides/[slug]/page.tsx renders the same <GuideView />. One shared body, two routes, zero hops — and the canonical tag keeps SEO honest. redirect() is still the right tool for auth gates and moved URLs; it is the wrong tool for "this index page should show its first child".
Fix 2 — gate the reveal on route commit + paint, never on a timer
Even with the hop gone, a reveal gated on a timer is a bug waiting for a slow network. The overlay must lift when the destination is actually committed and painted — not when a setTimeout merely correlates with it.
usePathname() is the honest signal: it only changes once the router has committed the destination. Two requestAnimationFrame hops later, that destination has laid out and painted under the overlay:
const pathName = usePathname();
useEffect(() => {
// Only act mid-transition, while the overlay is covering the screen.
if (usePageStore.getState().pageStatus !== PageState.LEAVE) return;
const reveal = () => revealRef.current?.();
const frame = requestAnimationFrame(() => requestAnimationFrame(reveal));
// Failsafe: a stuck overlay is a white page with no way out. Always leave an exit.
const failsafe = window.setTimeout(reveal, 2000);
return () => {
cancelAnimationFrame(frame);
clearTimeout(failsafe);
};
}, [pathName]);
Two rules baked into that block:
- Make
reveal()idempotent. The failsafe and the rAF path can both fire; the second call must be a no-op. - Always ship the failsafe. A reveal that never runs is strictly worse than a reveal that runs early — one is an ugly frame, the other is a dead site.
Fix 3 — reveal on mount if the page already settled
The safety net for any component that mounts late — async chunks, re-keyed lists, anything below a suspense boundary. If the page already moved past LEAVE, the one-shot event is gone; don't wait for a second one that will never come:
useEffect(() => {
const status = usePageStore.getState().pageStatus;
if (status === PageState.PLAY || status === PageState.ENTER) ctxAnimation();
}, []);
Read the store's current value on mount instead of only subscribing to future changes. That's a good pattern in any event-gated animation system — and it's the same discipline that keeps GSAP ScrollTrigger reveals from getting stuck when their trigger mounts after the scroll position has already passed it.
The lesson: fix the route, not the animation
Here's the part I got wrong, and the reason this post exists.
I wrote fix 3 first. It made the blank page go away, so it looked like a fix. It was a symptom patch — it caught the late-mounting components, but it left the redirect hop sitting in the router, still committing an empty page on every in-app navigation. A week later that same hop resurfaced as a white flash and a layout shift, and I got to debug the same link twice.
| Approach | Fixes blank page | Fixes flash / layout shift | Survives the next new link |
|---|---|---|---|
Patch each <Link> to point at the child slug | ✅ | ✅ | ❌ whack-a-mole |
| Reveal-on-mount if already settled (fix 3) | ✅ | ❌ | ⚠️ symptom patch |
| Gate reveal on route commit + paint (fix 2) | ✅ | ✅ | ✅ |
| Delete the redirect, render inline (fix 1) | ✅ | ✅ | ✅ durable |
Two rules earned, and I now apply both by default:
- A server
redirect()is not a navigable route in an SPA transition. If a URL can be clicked from inside the app, it must render, not bounce. - A transition reveal must be gated on the destination being committed and painted — never on a timer that merely correlates with it.
Fixes 2 and 3 make the system robust. Fix 1 makes the bug not exist. Do all three, but know which one is which — because a patch that hides a symptom will hand it back to you later, wearing a mask you don't recognise.
FAQ
Why does the page transition only break on in-app navigation, not a direct load?
A direct load has no redirect hop to observe — the server resolves the redirect before the browser ever paints, so your React tree mounts exactly once, at the final URL. A client-side navigation commits the redirecting route as a real page first. That asymmetry (works on direct load, fails via <Link>) is the fastest way to identify a lifecycle bug.
Can't I just add a delay so the reveal waits for the real page?
No — that's the bug I already shipped. A timer only correlates with the destination arriving; on a slow network or a cold cache, it fires early and reveals the stub. Gate on usePathname() changing (the commit) plus two rAFs (the paint), and keep a long failsafe timeout purely as an escape hatch.
Is redirect() in the App Router bad?
Not at all — it's correct for auth gates, locale bounces, and permanently moved URLs, where the extra hop is invisible or unavoidable. It's wrong specifically when the redirecting URL is a click target inside your own SPA, because your transition then has to animate a page that exists for one commit and holds no content.
How do I catch this class of bug before a client does?
Sample location.pathname, overlay opacity, and document.body.scrollHeight once per requestAnimationFrame across every nav path in the site (menu, CTA, footer, breadcrumb) and diff the rows. Any route that produces a short-height frame between two full-height frames is a redirect hop, and it will break your transition eventually.
If a custom page transition is misbehaving on a real project — or a menu link is quietly costing you a white flash on every visit — that's the kind of thing I fix for a living. See how I work with teams.


