# Next.js App Router Page Transitions: Why Exit Animations Don't Run

> 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.

- URL: https://qodesign.dev/blog/nextjs-app-router-page-transitions-animatepresence
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/tabbar-liquid), 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.

## What the broken version looks like

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.tsx
import { 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:

```txt
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.

## Why AnimatePresence exit doesn't work in the App Router

`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.

## Fix 1: freeze the router context for the exiting page

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.tsx
import { PageTransition } from "@/components/PageTransition";

export default function Layout({ children }: { children: React.ReactNode }) {
  return <PageTransition>{children}</PageTransition>;
}
```

Same click, A to B:

```txt
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.

## Fix 2: React's ViewTransition (no exit code at all)

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.tsx
import { ViewTransition } from "react";
import "./vt.css";

export default function Layout({ children }: { children: React.ReactNode }) {
  return <ViewTransition>{children}</ViewTransition>;
}
```

The animation lives in CSS:

```css
/* app/vt/vt.css */
::view-transition-old(*) {
  animation: 200ms ease-in both vt-fade-out;
}
::view-transition-new(*) {
  animation: 300ms ease-out both vt-fade-in;
}
@keyframes vt-fade-out {
  to { opacity: 0; transform: translateY(-8px); }
}
@keyframes vt-fade-in {
  from { opacity: 0; transform: translateY(8px); }
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}
```

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.

## What template.tsx actually does

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.

## Which one should you use?

- 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.

## Pairing it with an animated tab bar

Route transitions look best when the navigation itself is animated and stays put. qodesign's [Liquid Glass Tab Bar](/components/tabbar-liquid) calls `router.push` from its `onTabChange` handler, so it lives in the layout while the pages swap underneath it. The glass is plain CSS, and I wrote down [the exact Tailwind classes for the liquid glass effect](/react/liquid-glass-effect-react). There's a [Next.js guide for the tab bar](/react/animated-tab-bar), and I wrote up [how I wire it into the App Router](/blog/animated-tab-bar-nextjs-app-router). For a top-of-screen version, the [Dynamic Island navbar](/components/dynamic-island) works the same way. If you're still deciding between Motion and plain CSS for the small stuff, [this comparison](/blog/framer-motion-vs-css-micro-interactions) covers it, and anything that moves should respect [prefers-reduced-motion](/blog/prefers-reduced-motion-react). Free pieces with full source are at [/free](/free).

## FAQ

### Why is my AnimatePresence exit animation not working in Next.js?

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>`.

### Can I use template.tsx for page transitions?

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.

### Does Next.js support the View Transitions API?

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.

### Is the frozen router approach safe for production?

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.
