Framer Motion in Next.js App Router Without Layout Shift
By Nayan Kyada · · 6 min read
Part of Next.js Performance
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 how to avoid it.
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.- Know the limit of this pattern: because
template.tsxremounts on navigation, theAnimatePresenceinside it remounts too, so the outgoing page is removed rather than animated out. You reliably get enter animations; true exit animations in the App Router need extra work (for example, keeping the previous route's tree alive in a layout-level client component), which relies on Next.js internals and is fragile across upgrades. For most content sites, enter-only transitions are the safer choice. - 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
The full motion component adds roughly 34 kB gzipped to your client bundle (per the Motion docs). On a content-heavy site where most pages don't need animations, that's dead weight on every route.
Don't reach for next/dynamic with ssr: false here. In the App Router, ssr: false is not allowed in Server Components (and template.tsx is one unless you mark it 'use client'), and the loading fallback of next/dynamic doesn't receive children — a "passthrough" fallback would render nothing until the chunk arrives.
The library's own answer is LazyMotion plus the lightweight m component: the initial render ships a small core, and the animation features load asynchronously.
// lib/motion-features.ts
import { domAnimation } from 'framer-motion'
export default domAnimation// app/template.tsx
'use client'
import { AnimatePresence, LazyMotion, m } from 'framer-motion'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
// Animation features are code-split and loaded after first render
const loadFeatures = () => import('@/lib/motion-features').then((mod) => mod.default)
export default function Template({ children }: { children: ReactNode }) {
const pathname = usePathname()
return (
<LazyMotion features={loadFeatures}>
<AnimatePresence mode="wait" initial={false}>
<m.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}
</m.div>
</AnimatePresence>
</LazyMotion>
)
}The page content is still server-rendered inside the wrapper, so nothing is delayed while the features load. Per the Motion docs, m with LazyMotion is just under 5 kB for the initial render, with domAnimation adding roughly 15 kB once it loads. (Framer Motion is now published as motion — import from motion/react and motion/react-m in new projects; the framer-motion package still exports the same APIs.)
In @next/bundle-analyzer output, you'll see the animation features in a separate lazily-loaded chunk instead of the main client chunk.
Checklist before you ship
template.tsxis the animation host, notlayout.tsxAnimatePresencehasmode="wait"andinitial={false}- Every page's root element has a reserved min-height
- Images inside animated pages have explicit dimensions
- Animation features are lazy-loaded with
LazyMotionand themcomponent - 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.
Need a polished Next.js frontend?
If you're building a Next.js site and want smooth animations, strong Core Web Vitals, and a Sanity CMS integration handled properly, I work on frontend projects for startups and agencies. Fixed-price proposals, 24-hour response.
Frequently asked questions
01Can I use Framer Motion inside a Next.js App Router Server Component?
No. Framer Motion's `motion` components and hooks rely on browser APIs and React state, so any file that imports them needs a `'use client'` directive at the top. Keep the client boundary as small as possible — wrap just the animated template, not your whole layout — to avoid pulling unrelated server-rendered content into the client bundle.
02Should AnimatePresence go in layout.tsx or template.tsx in the App Router?
template.tsx for enter animations. Unlike layout.tsx, which persists across navigations and does not remount, template.tsx remounts on every route change, so the keyed motion wrapper runs its enter animation on each navigation. The catch: AnimatePresence inside template.tsx remounts too, so exit animations don't reliably play — true exit transitions in the App Router need a layout-level workaround that depends on Next.js internals.