Page Transitions in Next.js App Router: What Failed and What Worked
Why AnimatePresence flickers, why template.tsx does nothing, and why pathname as key is the answer
On this page
Adding a simple fade transition to the main content area sounds like frontend 101. In the Next.js App Router, it took three attempts to get right — including a memorable "content → blank → content" triple flash along the way.
This post documents the full debugging journey and the final solution.
The Goal
On route changes, fade in the main content area. Nav and footer stay put; only the content region animates.
The reference feel: cheyuwu.com — subtle, not flashy, but enough to signal that navigation happened.
Attempt 1: layout + AnimatePresence (failed — flicker)
The obvious approach: wrap children in layout.tsx with framer-motion's AnimatePresence for a fade-out-then-fade-in sequence.
<AnimatePresence mode="wait"> <motion.div key={pathname} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} > {children} </motion.div></AnimatePresence>What happened: Every navigation flashed — like "content appears → blank → content appears again."
Why: children in layout.tsx is a shared slot. On navigation, Next.js immediately injects the new page content — it does not wait for the exit animation to finish.
The timeline:
- New content is already rendered (flash)
- Old content's exit animation runs (blank)
- New content's enter animation runs (appears again)
This is not a performance issue. It is an architectural mismatch between layout + AnimatePresence and the App Router's children update model.
Attempt 2: template.tsx (failed — no effect)
Docs suggest template.tsx remounts on navigation — perfect for enter animations, right?
'use client'
export default function Template({ children }) { return ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}> {children} </motion.div> )}What happened: No animation at all when switching between home and the writing page.
Why: template.tsx only remounts when its own segment changes.
Our file lives at app/[locale]/template.tsx. Navigating from / to /writing does not change locale (both are zh) — only the child segment changes. The template never remounts, so the animation never fires.
From the Next.js docs:
Templates remount when that segment (including its dynamic params) changes. Navigations within deeper segments do not remount higher-level templates.
For template-based animations to work here, the file would need to sit at a segment level that changes on every page navigation — or you accept it only covers cases like locale switches.
Attempt 3: layout fade-in with pathname as key (works)
Back to layout, but with a different strategy: skip exit animation, only fade in. Use key={pathname} so the motion.div fully remounts on route change.
'use client'
import { usePathname } from '@/i18n/routing'import { motion } from 'framer-motion'
const isFirst = { current: true }
export default function PageTransition({ children }: { children: React.ReactNode }) { const pathname = usePathname() const skipInitial = isFirst.current
useEffect(() => { if (isFirst.current) isFirst.current = false }, [])
return ( <motion.div key={pathname} initial={skipInitial ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3, ease: 'easeOut' }} > {children} </motion.div> )}In layout.tsx:
<main className="flex-1"> <PageTransition>{children}</PageTransition></main>Result: Old page vanishes instantly. New page fades in over 300ms. Clean, no flicker.
Why does this work?
| Detail | Purpose |
|---|---|
key={pathname} | Route change destroys the old motion.div and creates a new one, triggering initial → animate |
No AnimatePresence | Avoids fighting the children update timing |
initial={false} on first load | No animation on the initial page visit |
usePathname from @/i18n/routing | Path without locale prefix — /writing is just /writing |
The core idea: do not fight the App Router's children update model. When the new page arrives, put it in a fresh container and fade that in.
What about bidirectional animations?
Fade-in only is the most reliable approach in the App Router today. For true fade-out + fade-in, the options are:
- React 19's
<ViewTransition>with Next.js 16'sexperimental.viewTransition(browser-native View Transitions API) - Accept the trade-off: old page disappears instantly, new page fades in (good enough for most blogs)
I chose the latter for this site. Simple, zero flicker, easy to maintain.
Summary
| Approach | Result | Root cause |
|---|---|---|
| layout + AnimatePresence | Triple flash | children updates immediately; exit/enter timing conflict |
[locale]/template.tsx | No effect | Same-locale navigation does not trigger remount |
layout + key={pathname} fade-in | Works | New container mounts and fades in; works with the framework |
Page transition animations in the App Router are less about picking an animation library and more about understanding the lifecycle of layout, template, and children. Once that clicks, a dozen lines of code is enough.