Framer Motion and the Next.js App Router fight each other more than you'd expect. The default AnimatePresence pattern assumes a single root that owns the route key, but App Router renders layouts and pages as separate RSC subtrees. Wire it up naively and you get a visible layout shift on every route change — elements jump before the exit animation runs, CLS spikes, and your Lighthouse score takes the hit. Here is exactly how I solved it on a production site.
Why framer motion causes layout shift in Next.js App Router
Classic Pages Router page transitions worked because _app.tsx was a single client component that wrapped every page. You'd pass router.pathname as the AnimatePresence key and the library handled mount/unmount cleanly.
App Router breaks that assumption in two ways:
-
layout.tsxis a server component by default, and it persists across navigations — it does not remount when the route changes. - The
{children}slot is replaced asynchronously. If your motion wrapper tries to animate an element that hasn't been assigned a stable height yet, the browser shifts content before the paint — that is your CLS.
The fix is to push AnimatePresence down to a dedicated client component that wraps only the page slot, not the whole layout, and to give every animated wrapper an explicit min-height or reserved space so the browser can calculate the layout before the animation starts.
Setting up the client template wrapper
Create a TemplateWrapper client component. Next.js App Router exposes a template.tsx file that does remount on every navigation — this is the right insertion point.
// app/template.tsx
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { usePathname } from 'next/navigation'
export default function Template({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: 'easeInOut' }}
// Reserve the space the page will occupy so CLS = 0
style={{ minHeight: 'var(--page-min-height, 100dvh)' }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
A few things worth noting here:
-
mode="wait"tells AnimatePresence to finish the exit animation before mounting the incoming page. Without this, both pages exist in the DOM simultaneously and the layout jumps. -
initial={false}prevents the animation firing on the very first load, which would cause a flash on hydration. - The
minHeightCSS custom property is the CLS fix. Set--page-min-heighton:rootin your global CSS to the shortest page height you're comfortable reserving (I use100dvh). This gives the browser a stable block size to work with before the content arrives.
Keep layout.tsx as a server component — do not convert it to a client component just to get AnimatePresence in there. The template file is the correct escape hatch.
Eliminating CLS: the reserved space pattern
The motion wrapper alone isn't enough if your page content causes a reflow after hydration. Two common culprits:
Fonts loading late — use next/font with display: swap and set explicit line heights in your CSS so the fallback font occupies the same block size as the web font.
Images without dimensions — if you're rendering images inside the animated page, make sure every <Image> from next/image has explicit width and height props or fill with a sized parent. An unsized image inside an opacity: 0 div still participates in layout, and when it loads it shifts everything.
In Chrome DevTools → Performance tab → Layout Shifts, you can verify CLS is 0 after the transition. The culprit element is highlighted in the "Experience" row — fix those before tweaking animation curves.
Lazy-loading Framer Motion to reduce bundle size
Framer Motion adds around 45–55 kB gzipped to your client bundle. On a content-heavy site where most pages don't need animations, that's dead weight on every route.
The pattern I use: dynamic import the wrapper with next/dynamic and a loading fallback that renders children immediately. This way the page is interactive before Framer Motion loads, and the animation plays in as a progressive enhancement.
// app/template.tsx
import dynamic from 'next/dynamic'
import type { ReactNode } from 'react'
const AnimatedWrapper = dynamic(
() => import('@/components/animated-wrapper'),
{
ssr: false,
// Render children immediately so there's no content delay
loading: ({ children }) => <>{children}</>,
}
)
export default function Template({ children }: { children: ReactNode }) {
return <AnimatedWrapper>{children}</AnimatedWrapper>
}
// components/animated-wrapper.tsx
'use client'
import { AnimatePresence, motion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
export default function AnimatedWrapper({ children }: { children: ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.18, ease: 'easeInOut' }}
style={{ minHeight: 'var(--page-min-height, 100dvh)' }}
>
{children}
</motion.div>
</AnimatePresence>
)
}
With ssr: false, Framer Motion is excluded from the server render entirely. The loading fallback returns children directly, so the page renders without any layout wrapper change when JS is slow or disabled. The Framer Motion chunk loads in parallel with the rest of the client JS and the animation runs once it's available.
In @next/bundle-analyzer output, you'll see framer-motion moves from the main client chunk into a separate lazily-loaded chunk. On a site I shipped last quarter, this dropped the initial JS parse time by ~80 ms on a mid-range Android device.
Checklist before you ship
-
template.tsxis the animation host, notlayout.tsx -
AnimatePresencehasmode="wait"andinitial={false} - Every page's root element has a reserved min-height
- Images inside animated pages have explicit dimensions
- Framer Motion is dynamically imported with
ssr: falseand a passthrough loading fallback - Run Lighthouse or Chrome DevTools CLS audit on at least two route transitions before deploying
The combination of template.tsx remounting on navigation, reserved block space, and lazy-loading the library gets you smooth transitions, zero CLS, and no unnecessary bundle weight on first load.
