TL;DR In the App Router, the exiting copy of a page reads the new route, so AnimatePresence fades out the page you're going to. Freeze the router context for the exiting page to keep Motion, or wrap pages in React's ViewTransition on Next.js 16 and animate with CSS. template.tsx only animates pages in.
Page transitions in the Next.js App Router break in one specific way: the exit animation runs, but it fades out the page you're going to, not the page you're leaving. Wrapping children in AnimatePresence inside a layout doesn't fix it, and template.tsx only gives you the enter half. There are two setups that work today. One keeps the old page on screen with a small "frozen router" wrapper around Motion's AnimatePresence. The other is React's <ViewTransition>, which Next.js 16 drives on every client navigation.
I build animated navigation for qodesign, including a tab bar that switches routes, so I tested all four setups side by side on a fresh Next.js 16.4.2 app with React 19.3.1, Motion 14.4 and Tailwind CSS 4.3. Each one went through strict tsc, next build and next start, then headless Chromium clicked a link and sampled which page was on screen, and at what opacity, every 40 ms.
This is the version most tutorials write, and it's what Cursor and v0 tend to produce when you ask for "page transitions with framer motion":
tsx
// components/NaivePageTransition.tsx"use client";import { AnimatePresence, motion } from "motion/react";import { usePathname } from "next/navigation";// The version most tutorials (and most AI tools) write first.export function NaivePageTransition({ children }: { children: React.ReactNode }) { const pathname = usePathname(); return ( <AnimatePresence mode="wait"> <motion.div key={pathname} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.4 }} > {children} </motion.div> </AnimatePresence> );}
tsx
// app/naive/layout.tsximport { NaivePageTransition } from "@/components/NaivePageTransition";export default function Layout({ children }: { children: React.ReactNode }) { return <NaivePageTransition>{children}</NaivePageTransition>;}
The two test pages each render a heading ("Page A", "Page B") and a link to the other. I clicked from A to B and logged what was visible:
0.01s B 1.00
0.18s B 0.43
0.40s B 0.01
0.57s B 0.53
0.83s B 1.00
Page A never shows up in the exit. The heading switches to "Page B" in the first frame, fades out, and then fades back in. On a real site that reads as the new page blinking.
Liquid Glass Tab Bar running live. A glass drop magnifies the tab under it, stretches between tabs and bursts into the player.
AnimatePresence does its part. When the key changes it keeps the old motion.div mounted until the exit finishes. The trouble is what's inside that div. In the App Router, the children a layout receives isn't the page itself. It's a router element that reads the current route from React context. The exiting copy still holds the old element, but the context it reads has already moved to the new route, so it renders page B.
In the Pages Router this worked because _app got the page component as a prop, and the exiting copy kept the old component. That's why so many older answers say "just add a key" and why that advice doesn't carry over.
The fix is to capture the router context when each page mounts and keep feeding that captured value to it, even after the route changes:
tsx
// components/PageTransition.tsx"use client";import { useContext, useRef } from "react";import { AnimatePresence, motion } from "motion/react";import { usePathname } from "next/navigation";import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";// Keeps the router context the page had when it mounted, so the// exiting copy keeps showing the old page instead of the new one.function FrozenRouter({ children }: { children: React.ReactNode }) { const context = useContext(LayoutRouterContext); const frozen = useRef(context).current; return ( <LayoutRouterContext.Provider value={frozen}> {children} </LayoutRouterContext.Provider> );}export function PageTransition({ 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.4 }} > <FrozenRouter>{children}</FrozenRouter> </motion.div> </AnimatePresence> );}
tsx
// app/frozen/layout.tsximport { PageTransition } from "@/components/PageTransition";export default function Layout({ children }: { children: React.ReactNode }) { return <PageTransition>{children}</PageTransition>;}
Same click, A to B:
0.00s A 1.00
0.22s A 0.33
0.39s A 0.01
0.43s B 0.00
0.61s B 0.62
0.82s B 1.00
Page A fades out, then page B fades in. The browser back button ran the same sequence in reverse (B, then A), and none of the four click tests logged a console error.
A caveat: LayoutRouterContext comes from next/dist/shared/lib/..., which is an internal Next.js path, not a documented API. It built and ran on 16.4.2, and TypeScript resolved its types, but a future Next.js release can move it. If it breaks after an upgrade, that import line is where to look.
mode="wait" matters here. Without it the old and new pages render at the same time, stacked on top of each other, unless you position the exiting one absolutely. initial={false} skips the enter animation on the first page load, so the page doesn't fade in after a hard refresh.
Next.js 16 runs client navigations inside a React transition, so React's <ViewTransition> component can animate them with the browser's View Transitions API. The layout is three lines:
tsx
// app/vt/layout.tsximport { ViewTransition } from "react";import "./vt.css";export default function Layout({ children }: { children: React.ReactNode }) { return <ViewTransition>{children}</ViewTransition>;}
In the test, one click started exactly one view transition. Reading document.getAnimations() once the transition was ready showed the old snapshot running vt-fade-out for 200 ms and the new one running vt-fade-in for 300 ms. With reduced motion turned on in Chromium, both custom animations were gone and only the browser's own 250 ms group animation was left, which for a page that keeps its size and position doesn't move anything.
The DOM swaps to page B right away. The fade happens on snapshots the browser draws on top, so there's no frozen-router trick and no internal import. The trade-offs: you style with CSS keyframes instead of Motion props, browsers without the View Transitions API just swap pages with no animation, and you can't run spring physics or gestures on the snapshots. Next.js 16.4's <Link> also takes a transitionTypes prop, which passes types to React so different links can pick different animations. I didn't test that part.
A lot of answers point to template.tsx, because Next.js gives it a fresh instance on every navigation:
tsx
// app/tpl/template.tsx"use client";import { motion } from "motion/react";// template.tsx gets a fresh instance on every navigation:// enter animations run, exit animations never do.export default function Template({ children }: { children: React.ReactNode }) { return ( <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} > {children} </motion.div> );}
In the test, page B was on screen at opacity 0.11 one sample after the click and reached 1.00 at 0.44 s. Page A was simply gone. That's correct behavior. A template remounts, which is good for enter animations, resetting state, or logging page views, but it can't hold the old page back, so there's nothing to animate out. Use it when an enter-only fade is all you want.
You want a quick fade or slide between routes and you're on Next.js 16: use <ViewTransition>. It's the least code and uses public APIs only.
You need Motion features on the route change (springs, shared layoutId elements, drag to dismiss): use the frozen-router PageTransition, and keep an eye on that import when you upgrade.
You only want pages to animate in: template.tsx.
Animations inside a page, like tabs, modals, toasts or a cart drawer, don't have this problem. Their AnimatePresence sits below the router and the exiting element keeps its own props and state, so exits there work the way the Motion docs show.
In the App Router, the children your layout wraps reads the current route from context. When the route changes, the exiting copy reads the new route too, so it renders the new page while it fades out. Freeze the router context for the exiting page, or use React's <ViewTransition>.
For enter animations, yes. A template gets a new instance on every navigation, so initial and animate run each time. Exit animations never run, because the old template is removed before anything can animate it.
Next.js 16 runs navigations in a React transition, so wrapping pages in React's <ViewTransition> component animates route changes with the browser's View Transitions API. Browsers without the API swap pages without animating.
It works on Next.js 16.4.2 in my tests, including the back button. It relies on LayoutRouterContext from an internal Next.js path, so check it after every Next.js upgrade.