cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Migrating to the App Router

Move a Pages Router app to the App Router one route at a time: both routers side by side, data functions, layouts, metadata and navigation hooks.

By the end, you can
  • 01
    Run both routers in one appAdd an app folder beside pages, read the two route tables, and know what changes when a link crosses from one router to the other.
  • 02
    Move a route without breaking itMove one page at a time, replace its data function, and give it a root layout and metadata in place of _app, _document and Head.
  • 03
    Plan the migrationSwap next/router for next/navigation in shared code, and choose which pages to move first and which to leave in pages.

One shop, two routers

The tea shop from Data fetching in the Pages Router works. Moving all of it to the App Router in one change would mean new files, new data code and new layouts at once, with nothing working until everything does. Next.js doesn't ask for that: the app folder runs beside the pages folder in the same project, so you can move one route at a time. Here is the shop after the first move. The cart is now app/cart/page.tsx, and everything else is still in pages. It was built with next build, run with next start, and recorded:

The tea shop with its cart moved to the App RouterNext.js
app/cart/components/data/lib/pages/teas/styles/
app/cart/page.tsx
import { Suspense } from "react";import { cookies, headers } from "next/headers";import { getTea } from "@/lib/teas"; export const metadata = { title: "Your cart" }; async function Cart() {  const tea = await getTea((await cookies()).get("cart")?.value ?? "");  const french = (await headers()).get("accept-language")?.startsWith("fr") ?? false;  return (    <>      <h1>{french ? "Bonjour" : "Hello"}, here is your cart</h1>      <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>    </>  );} export default function CartPage() {  return (    <Suspense fallback={<p>Loading your cart…</p>}>      <Cart />    </Suspense>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...   We detected TypeScript in your project and reconfigured your tsconfig.json file for you.  The following suggested values were added to your tsconfig.json. These values can be changed to fit your project's needs:   	- plugins was updated to add { name: 'next' }   Finished TypeScript ...  Collecting page data ...✓ Generating static pages (8/8)  Finalizing page optimization ... Route (app)┌ ○ /_not-found└ ◐ /cart Route (pages)┌ ● /├   /_app└   /teas/[id]  ├ ● /teas/green  ├ ● /teas/mint  └ ● /teas/lemon ○  (Static)             prerendered as static content●  (SSG)                prerendered as static HTML (uses generateStaticParams)◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

app/cart/page.tsx is close to the last lesson's App Router cart: Cart, on lines 7 to 16, reads the cookie and the header, and the Suspense on lines 20 to 22 lets the rest of the page go out first. Two more files changed. app/layout.tsx is a root layout, the file every App Router page needs: it renders the <html> and <body> tags. And in next.config.ts, lines 6 and 7 turn on Cache Components and Partial Prefetching, as a new App Router project has them. pages/cart.tsx is gone.

Open the Terminal tab. The build printed two route tables. Route (app) lists ◐ /cart and the /_not-found page the App Router adds. Route (pages) lists the home page and the tea pages, made with getStaticProps as before. The build also added the next plugin to tsconfig.json, and said so. create-next-app writes it for App Router projects, not for Pages Router ones.

An editor window for the project lists app/layout.tsx and app/cart/page.tsx, then pages/_app.tsx, pages/index.tsx and pages/teas/[id].tsx. Arrows lead from the app files to the App Router, which answers slash cart, and from the pages files to the Pages Router, which answers slash and slash teas slash mint.my-appapp/layout.tsxapp/cart/page.tsxpages/_app.tsxpages/index.tsxpages/teas/[id].tsxApp Router/cartPages Router//teas/…
One project, two folders. app/cart/page.tsx makes /cart an App Router page, wrapped by app/layout.tsx. Everything still in pages stays with the Pages Router, wrapped by _app. An address can only be in one of them.
Real-life analogyA shop that moves next door, one counter at a time

A tea shop moves to the building next door without closing. The tea counter moves first, then the cash desk, a few weeks apart. Each counter is in one building at a time, so customers always know where to go. Walking between the buildings means going out one door and in the other, which takes longer than walking to the next counter inside.

In real life: The old building, still open for business
In Next.js: The pages folder and the Pages Router
In real life: The new building next door
In Next.js: The app folder and the App Router
In real life: Each counter is in one building, never both
In Next.js: Each address is in one folder; the build stops if it's in both
In real life: Walking out one door and in the other
In Next.js: A link between the routers: a full page load
The main idea

An app can run both routers at once, so you migrate one route at a time. A route moves when its file moves from pages to app, and its data function, its layout and its head tags become App Router code. Each address lives in one router; a link from one router to the other loads a whole new page.

Both routers in one build

Each router keeps its own rules. Pick /cart in the Network tab of the example above: its HTML is the App Router's, with the shell's Loading your cart… first and the cart streamed in after it. Pick /teas/mint: its HTML is the Pages Router's, with a <div id="__next"> and the __NEXT_DATA__ script, served from the build with x-nextjs-cache: HIT. The legend under the tables now says ● means prerendered as static HTML (uses generateStaticParams), the App Router's wording. In the Pages Router table, the same symbol still marks pages made with getStaticProps.

A link between the routers is a full page load

Each router has its own code in the browser. A Link inside one router swaps the page in place, but a Link to a page of the other router loads that page as a new document. The recorder opened /teas/mint (Pages Router) in real Chrome, typed mint in the search box, clicked Cart (App Router), then Teas (Pages Router again). Open the Steps tab:

Links between the two routersNext.js
app/cart/components/data/lib/pages/teas/styles/
app/layout.tsx
import "@/styles/globals.css";import type { Metadata } from "next";import Layout from "@/components/layout"; export const metadata: Metadata = {  title: { default: "Tea shop", template: "%s | Tea shop" },}; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body className="tea-shop">        <Layout>{children}</Layout>      </body>    </html>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/cartYour cart | Tea shop
TeasCart
Hello, here is your cart

Your cart is empty.

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (2), without the files it loaded

  1. GET/cart200a whole page
  2. GET/200text/html
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /cart

The click on Cart made a document request for /cart: a whole new page, not a client-side navigation, and the search box came back empty. On /cart, the App Router then asked for / as an RSC payload, the way it prefetches its own pages. The Pages Router answered with HTML, which the App Router can't use, and the click on Teas loaded / as a whole page too. The docs say navigating between the routers is a hard navigation, and that Link won't prefetch across them. In this 16.4.3 build, the App Router still sent prefetch requests to Pages Router pages; they just came back as HTML it couldn't use, so no prefetch across routers helped. Each new page brings a new copy of the layout, so anything kept in its state, like the search box's text, starts over.

Three pages in a row: home at slash and cart at slash cart in the App Router, and a tea at slash teas slash mint in the Pages Router. An arrow from slash to slash cart is labelled RSC payload, page swapped in place. An arrow from slash to slash teas slash mint crosses a dashed line between the routers and is labelled full page load, new HTML.App Routerhome/cart/cartRSC payload, swappedPages Routertea/teas/mintfull page load,new HTML
A click between two App Router pages fetches an RSC payload and swaps the page in place. A click from one router into the other loads a whole new page: new HTML, new JavaScript, and a fresh copy of the layout, so its state starts over.

Moving one route at a time

Moving the cart took three steps, done together in one change:

  1. Write app/cart/page.tsx, with the same address as pages/cart.tsx: the folder cart and a page.tsx inside it.
  2. Replace what the Pages Router did for it: the data function, and anything _app or next/head gave the page.
  3. Delete pages/cart.tsx.

The last step can't wait. Here is the shop with both cart files:

The cart in both folders (a build that fails on purpose)Next.js
app/cart/components/data/lib/pages/teas/styles/
pages/cart.tsx
import type { GetServerSidePropsContext } from "next";import { getTea } from "@/lib/teas"; export async function getServerSideProps({ req }: GetServerSidePropsContext) {  const tea = await getTea(req.cookies.cart ?? "");  const french = req.headers["accept-language"]?.startsWith("fr") ?? false;  return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };} type CartProps = { greeting: string; item: string | null }; export default function CartPage({ greeting, item }: CartProps) {  return (    <>      <h1>{greeting}, here is your cart</h1>      <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>    </>  );}
next build failed · Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./Error: App Router and Pages Router both match path: /cartNext.js does not support having both App Router and Pages Router routes matching the same path. Please remove one of the conflicting routes.     at ignore-listed frames

Turbopack stopped the build while it collected the routes, before any page was prerendered: App Router and Pages Router both match path: /cart. Next.js does not support having both App Router and Pages Router routes matching the same path. An address belongs to one router, so the new file and the deleted one go in the same commit.

The docs' migration guide suggests the gentlest first version of a moved page: put the old page component in a file that starts with "use client", and render it from the new page.tsx, which loads the data and passes it as props. The component then works as it did in pages, where every component also ran in the browser. Later, you move the parts that don't need the browser back into Server Components. The cart needs no browser code at all, so it became a Server Component straight away.

API routes don't have to move. The docs say pages/api keeps working without changes; a Route Handler in app/api/…/route.ts is the App Router's version, and Route Handlers teaches it. Move one only when you'd gain something, like Web Request and Response objects.

Replacing the data functions

The App Router has no getStaticProps or getServerSideProps: a page is a Server Component that waits for its own data. Here is the home page before and after its move, each built with real Next.js. The first version is the Pages Router shop from the last lesson; the other two are two lifetimes for the moved page. Before you click, guess what the build prints for / after the move.

Playground: the home page in each router
The tea shop, with the home page in this routerNext.js
components/data/lib/pages/teas/styles/
pages/index.tsx
import Head from "next/head";import Link from "next/link";import { getTeas, type Tea } from "@/lib/teas"; export async function getStaticProps() {  const teas = await getTeas();  return { props: { teas } };} export default function Home({ teas }: { teas: Tea[] }) {  return (    <>      <Head>        <title>Tea shop</title>      </Head>      <h1>Tea shop</h1>      <ul>        {teas.map((tea) => (          <li key={tea.id}>            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>          </li>        ))}      </ul>    </>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)   Running TypeScript ...  Finished TypeScript ...  Creating an optimized production build ...✓ Compiled successfully  Collecting page data ...✓ Generating static pages (6/6)  Finalizing page optimization ... Route (pages)┌ ● /├   /_app├ ○ /404└   /teas/[id]  ├ ● /teas/green  ├ ● /teas/mint  └ ● /teas/lemon ○  (Static)  prerendered as static content●  (SSG)     prerendered as static HTML (uses getStaticProps) # What .next/server/pages holds: 52 files, 85.8 kB (deeper folders added up).next/server/pages/404.html  2.8 kB.next/server/pages/500.html  2.7 kB.next/server/pages/_app.js  232 bytes.next/server/pages/_app.js.map  53 bytes.next/server/pages/_app.js.nft.json  12.8 kB.next/server/pages/_app/build-manifest.json  759 bytes.next/server/pages/_app/client-build-manifest.json  57 bytes.next/server/pages/_app/next-font-manifest.json  94 bytes.next/server/pages/_app/pages-manifest.json  30 bytes.next/server/pages/_app/react-loadable-manifest.json  2 bytes.next/server/pages/_document.js  256 bytes.next/server/pages/_document.js.map  53 bytes.next/server/pages/_document.js.nft.json  12.8 kB.next/server/pages/_document/next-font-manifest.json  94 bytes.next/server/pages/_document/pages-manifest.json  40 bytes.next/server/pages/_document/react-loadable-manifest.json  2 bytes.next/server/pages/_error.js  433 bytes.next/server/pages/_error.js.map  53 bytes.next/server/pages/_error.js.nft.json  13.2 kB.next/server/pages/_error/build-manifest.json  468 bytes.next/server/pages/_error/client-build-manifest.json  59 bytes.next/server/pages/_error/next-font-manifest.json  94 bytes.next/server/pages/_error/pages-manifest.json  34 bytes.next/server/pages/_error/react-loadable-manifest.json  2 bytes.next/server/pages/index.html  2.2 kB.next/server/pages/index.js  522 bytes.next/server/pages/index.js.map  53 bytes.next/server/pages/index.js.nft.json  13.3 kB.next/server/pages/index.json  169 bytes.next/server/pages/index.meta  178 bytes.next/server/pages/index/build-manifest.json  457 bytes.next/server/pages/index/client-build-manifest.json  53 bytes.next/server/pages/index/next-font-manifest.json  94 bytes.next/server/pages/index/pages-manifest.json  27 bytes.next/server/pages/index/react-loadable-manifest.json  2 bytes.next/server/pages/teas/[id].js  504 bytes.next/server/pages/teas/[id].js.map  53 bytes.next/server/pages/teas/[id].js.nft.json  13.7 kB.next/server/pages/teas/[id]/  673 bytes, 5 files.next/server/pages/teas/green.html  2.0 kB.next/server/pages/teas/green.json  80 bytes.next/server/pages/teas/green.meta  192 bytes.next/server/pages/teas/lemon.html  2.0 kB.next/server/pages/teas/lemon.json  80 bytes.next/server/pages/teas/lemon.meta  192 bytes.next/server/pages/teas/mint.html  2.0 kB.next/server/pages/teas/mint.json  78 bytes.next/server/pages/teas/mint.meta  191 bytes
Try it yourself
pages/index.tsx: getStaticProps

Lines 5 to 8 load the teas during the build and hand them to Home as props. The build printed ● /, wrote index.html and index.json, and kept them until the next build.

Every version was built and run with Next.js 16.4.3: the first is the Pages Router shop before the move, the others the shop with the cart and the home page in app, with two lifetimes. Reset goes back to the Pages Router.

In app/page.tsx, Home on line 5 is an async function, and line 8 waits for getTeas() while it renders. There's no pageProps JSON: the HTML and the RSC payload carry the values Home rendered, and its code and getTeas stay on the server. Lines 6 and 7 cache the result with cacheLife("max"), the closest built-in profile, but not the same lifetime. getStaticProps without revalidate kept its page until the next deploy or an on-demand revalidation. max revalidates after 30 days and expires after a year: the build printed ○ / 30d 1y, and once the page is 30 days old, the next request gets the stored page and starts a new one in the background (Cache lifetimes and tags lists the profiles).

The third version is nearer: an inline profile, cacheLife({ revalidate: Infinity, expire: Infinity }), on line 7. The docs list numbers of seconds; Next.js 16.4.3 also accepts Infinity for "never" (lines 50 to 54 of cache-life-profile.ts). Its build printed ○ / with no lifetime, and / came with Cache-Control: s-maxage=31536000, as the Pages Router's page did. Either way, a webhook can make the page again at once with revalidatePath("/").

pages/index.tsx before the moveTSX
import Head from "next/head";
import Link from "next/link";
import { getTeas, type Tea } from "@/lib/teas";

export async function getStaticProps() {
  const teas = await getTeas();
  return { props: { teas } };
}

export default function Home({ teas }: { teas: Tea[] }) {
  return (
    <>
      <Head>
        <title>Tea shop</title>
      </Head>
      <h1>Tea shop</h1>
      <ul>
        {teas.map((tea) => (
          <li key={tea.id}>
            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}
app/page.tsx after the moveTSX
import Link from "next/link";
import { cacheLife } from "next/cache";
import { getTeas } from "@/lib/teas";

export default async function Home() {
  "use cache";
  cacheLife("max");
  const teas = await getTeas();
  return (
    <>
      <h1>Tea shop</h1>
      <ul>
        {teas.map((tea) => (
          <li key={tea.id}>
            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

Lines 5 to 8 of the old file, getStaticProps, became lines 6 to 8 of the new one. The Head on lines 13 to 15 went away too: the root layout's metadata gives the page its title. The cart moved the same way, from getServerSideProps to cookies() and headers() inside Suspense. Each row is one piece of Pages Router code and what replaced it in the shop:

The home page and the cart, before and after
In pagesIn app
export async function getStaticProps()The page itself is an async Server Component that awaits getTeas().
No revalidate: kept until the next deploy or an on-demand revalidation"use cache" with cacheLife("max"), the closest built-in profile: ○ / 30d 1y. Nearer still: cacheLife({ revalidate: Infinity, expire: Infinity }), which built as ○ /.
props: { teas }, sent to the browser as JSONNo pageProps JSON: the HTML and the RSC payload carry what Home rendered; its code and getTeas stay on the server.
getServerSideProps({ req })await cookies() and await headers() in a component inside Suspense.
req.cookies.cart(await cookies()).get("cart")?.value
<Head><title>…</title></Head>export const metadata = { title: "Your cart" }

The docs' guide shows these moves with fetch options from the caching model before Cache Components: cache: "force-cache" for getStaticProps, cache: "no-store" for getServerSideProps, next: { revalidate } for ISR. With Cache Components on, as in this shop, "use cache" and cacheLife say the same thing, and request-time data goes inside Suspense. The tea pages, with getStaticPaths and fallback, would become generateStaticParams; the last lesson's table maps each setting.

Layouts and metadata instead of _app, _document and Head

In the first move, the cart lost something. pages/_app.tsx wrapped every page in the shop's Layout, with the Teas link and the search box, and imported the global CSS. But _app and _document only wrap pages in the pages folder. App Router pages get their root layout instead. Pick each version below and look at the Browser tab:

Playground: what wraps the moved cart?
The tea shop, with this app/layout.tsxNext.js
app/cart/components/data/lib/pages/teas/styles/
app/layout.tsx
export const metadata = { title: "Tea shop" }; export default function RootLayout({ children }: LayoutProps<"/">) {  return (    <html lang="en">      <body>{children}</body>    </html>  );}
Recorded from Next.js 16.4.3
localhost:3000/cartYour cart
Bonjour, here is your cart

In your cart: Mint tea

Sent with cookie: cart=mintaccept-language: fr-FR,fr;q=0.9. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
Only <html> and <body>

pages/_app.tsx doesn't wrap App Router pages, and this root layout renders only children. The cart arrived without the Teas link, the search box and the global CSS.

Both versions were built and run with Next.js 16.4.3 and asked for /cart. Only app/layout.tsx and the shared components/layout.tsx change. Reset goes back to the bare layout.

With the bare root layout, /cart had no navigation at all. The second version gives App Router pages what the Pages Router's two files gave the others:

app/layout.tsx: the root layout, in place of _app and _documentTSX
import "@/styles/globals.css";
import type { Metadata } from "next";
import Layout from "@/components/layout";

export const metadata: Metadata = {
  title: { default: "Tea shop", template: "%s | Tea shop" },
};

export default function RootLayout({ children }: LayoutProps<"/">) {
  return (
    <html lang="en">
      <body className="tea-shop">
        <Layout>{children}</Layout>
      </body>
    </html>
  );
}
  • Line 1 imports styles/globals.css, which only _app could import in the Pages Router. In the App Router, any layout or page may.
  • Lines 11 and 12 write the <html lang> and the <body className> that _document wrote.
  • Line 13 renders the shop's Layout, as _app does. It's the same file, but each router has its own copy of it in the browser.
  • Lines 5 to 7 are metadata, in place of next/head: a default title, and a template that app/cart/page.tsx's title: "Your cart" fills. The recorded cart's title is Your cart | Tea shop.
components/layout.tsx, shared by both routersTSX
"use client";

import { useState, type ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";

export default function Layout({ children }: { children: ReactNode }) {
  const [search, setSearch] = useState("");
  const pathname = usePathname();
  return (
    <>
      <nav>
        <Link href="/" aria-current={pathname === "/" ? "page" : undefined}>Teas</Link>
        <Link href="/cart" aria-current={pathname === "/cart" ? "page" : undefined}>Cart</Link>
        <input aria-label="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} />
      </nav>
      <main>{children}</main>
    </>
  );
}

Layout keeps the search box's text in state, and the root layout is a Server Component, so line 1 marks the file with "use client" (Client Components explains why). The Pages Router builds the same file without complaint, as the recording shows: /teas/mint still has its nav. Line 9 reads the address with usePathname, and lines 13 and 14 mark the current link with aria-current="page", which the recorded /cart has on Cart. The docs add two more moves: a React context provider from _app goes in a Client Component too, and the getLayout pattern becomes nested layouts.

Left, the Pages Router: _document and _app wrap pages slash index and pages slash teas. Right, the App Router: app slash layout wraps app slash cart. Both wrappers render the same Layout component from components slash layout.tsx, shown below them, so each router has its own copy.Pages Router_document_appindexteas/[id]App Routerapp/layout<html> <body>cartLayoutcomponents/layout.tsx, a copy in each
_document and _app wrap only the pages in pages; app/layout.tsx wraps only the pages in app. Both can render the same Layout component, but each router has its own copy, with its own state.

What to move first

A half-moved app works, but it has a cost: every link that crosses from one router to the other is a full page load. Step through the shop after two moves: two App Router pages, then a tea page that's still in pages.

Playground: a click that crosses routers
Who does what/, then /cart, then /teas/mint
What the visitor seesafter step 1
localhost:3000/

Teas Cart

Tea shop

Green tea

Mint tea

Lemon tea

React is running: the page responds to clicks.

The page isn't complete yet.

Step 1 of 7
Browser: The visitor opens the home page

/ now comes from app/page.tsx. The browser loads it as a whole page, and the root layout's NavigationEvents logs page view: /.

Each step is one thing that happened in real Chrome, in order, from the recording above. Times aren't to scale. Reset goes back to the first step.

Between / and /cart, the clicks were client-side: one RSC request for the cart, none at all to go back, and mint stayed in the search box. The click on Mint tea loaded a whole page, and the box came back empty. The home page links to every tea, so until the tea pages move too, each of those clicks costs a full load. That points to a plan:

  • Start with leaf pages with simple data, like the cart: little code to replace, few links to other pages, and something to gain, like a shell that streams.
  • Move pages that link to each other together, like the home page and the tea pages, so the links between them stay client-side.
  • Make shared code work in both routers first, where it needs to: "use client" for a component with state, Effects or event handlers, and next/navigation's usePathname for one that reads the route. A component that only renders what it's given needs neither.
  • Keep what works. The Pages Router is still supported, and API routes keep working. A page that works and rarely changes can stay in pages as long as you like.
When should this part of the shop move?
  • The cart: one page, one data function, few links to other pages.
  • An About page with no data at all.
  • The tea pages, which the home page links to, with getStaticPaths and fallback.
  • The shared layout, used by _app on every page.
  • The content system's API routes in pages/api.
  • Old admin pages that work and rarely change.
Try it yourself
0 of 6 correct

Sort each part of the tea shop by when it should move to the App Router.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

No codemod does this move for you. The codemods page lists none that moves a page from pages to app; the ones the migration guide names, like new-link and next-image-to-legacy-image, update Link and Image code from Next.js 13, which works in both routers. Upgrading Next.js runs codemods. The steps here follow the docs' incremental migration guide, which also covers styles, Tailwind CSS, and single-page apps.

Common mix-ups

  • "You have to move the whole app at once." No. Both folders run in the same build, and the shop moved one route at a time.
  • "The App Router page wins if both folders have the route." No. The build stops: both routers match the same path.
  • "_app.tsx wraps every page." Only the pages in pages. App Router pages get the root layout, which has to render the shared layout itself.
  • "A link between the routers is a client-side navigation." No. It's a full page load, and the page's state starts over.
  • "next/router works anywhere." No. In the App Router, its useRouter stops the build: NextRouter was not mounted. usePathname from next/navigation works in both.
  • "There's a codemod that moves pages to app." No. The codemods page lists none; the move is done by hand, page by page.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upFind the router for an address

These are the shop's files after the first move. Which router answers /cart?

Starter codeText
app/
  cart/
    page.tsx
  layout.tsx
pages/
  teas/
    [id].tsx
  _app.tsx
  index.tsx

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upForget to delete the old page

    You add app/cart/page.tsx, and this pages/cart.tsx is still there. What does next build do?

    Starter codeTSX
    import type { GetServerSidePropsContext } from "next";
    import { getTea } from "@/lib/teas";
    
    export async function getServerSideProps({ req }: GetServerSidePropsContext) {
      const tea = await getTea(req.cookies.cart ?? "");
      const french = req.headers["accept-language"]?.startsWith("fr") ?? false;
      return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };
    }
    
    type CartProps = { greeting: string; item: string | null };
    
    export default function CartPage({ greeting, item }: CartProps) {
      return (
        <>
          <h1>{greeting}, here is your cart</h1>
          <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>
        </>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeMove the home page's data

      Write app/page.tsx for this home page. What replaces getStaticProps, so the page stays stored like it was?

      Starter codeTSX
      import Head from "next/head";
      import Link from "next/link";
      import { getTeas, type Tea } from "@/lib/teas";
      
      export async function getStaticProps() {
        const teas = await getTeas();
        return { props: { teas } };
      }
      
      export default function Home({ teas }: { teas: Tea[] }) {
        return (
          <>
            <Head>
              <title>Tea shop</title>
            </Head>
            <h1>Tea shop</h1>
            <ul>
              {teas.map((tea) => (
                <li key={tea.id}>
                  <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
                </li>
              ))}
            </ul>
          </>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the shared layout

        The root layout renders this layout, and the build stops with NextRouter was not mounted. Which hook should line 9 use?

        Starter codeTSX
        "use client";
        
        import { useState, type ReactNode } from "react";
        import Link from "next/link";
        import { useRouter } from "next/router";
        
        export default function Layout({ children }: { children: ReactNode }) {
          const [search, setSearch] = useState("");
          const { pathname } = useRouter();
          return (
            <>
              <nav>
                <Link href="/" aria-current={pathname === "/" ? "page" : undefined}>Teas</Link>
                <Link href="/cart" aria-current={pathname === "/cart" ? "page" : undefined}>Cart</Link>
                <input aria-label="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} />
              </nav>
              <main>{children}</main>
            </>
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeClick across the routers

          This is app/page.tsx, and the tea pages are still in pages. A visitor clicks Mint tea. What does the browser load?

          Starter codeTSX
          import Link from "next/link";
          import { cacheLife } from "next/cache";
          import { getTeas } from "@/lib/teas";
          
          export default async function Home() {
            "use cache";
            cacheLife("max");
            const teas = await getTeas();
            return (
              <>
                <h1>Tea shop</h1>
                <ul>
                  {teas.map((tea) => (
                    <li key={tea.id}>
                      <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
                    </li>
                  ))}
                </ul>
              </>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCount page views without router.events

            This Pages Router code logs a page view after each navigation. What does the same job in the App Router?

            Starter codeTSX
            // pages/_app.tsx, the Pages Router way
            useEffect(() => {
              const log = (url: string) => console.log(`page view: ${url}`);
              router.events.on("routeChangeComplete", log);
              return () => router.events.off("routeChangeComplete", log);
            }, [router.events]);

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which router answers the address, and what that router needs.

              The migrating to the App Router quiz · 7 questionsScore: first tries count
              1. Question 1 of 7A project has pages/index.tsx and app/cart/page.tsx. Which router answers each address?

                Choose an answer to see the explanation.

              2. Question 2 of 7You add app/cart/page.tsx and haven't deleted pages/cart.tsx yet. What happens?

                Choose an answer to see the explanation.

              3. Question 3 of 7This home page replaced one with getStaticProps. Where did the props go?

                Read the code, then predictTSX
                import Link from "next/link";
                import { cacheLife } from "next/cache";
                import { getTeas } from "@/lib/teas";
                
                export default async function Home() {
                  "use cache";
                  cacheLife("max");
                  const teas = await getTeas();
                  return (
                    <>
                      <h1>Tea shop</h1>
                      <ul>
                        {teas.map((tea) => (
                          <li key={tea.id}>
                            <Link href={`/teas/${tea.id}`}>{tea.name}</Link>
                          </li>
                        ))}
                      </ul>
                    </>
                  );
                }
                

                Choose an answer to see the explanation.

              4. Question 4 of 7The cart moved to app/ with this root layout, and lost the shop's Teas link and search box. Why?

                Read the code, then predictTSX
                export const metadata = { title: "Tea shop" };
                
                export default function RootLayout({ children }: LayoutProps<"/">) {
                  return (
                    <html lang="en">
                      <body>{children}</body>
                    </html>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7Why does the build stop when this shared layout renders in app/layout.tsx?

                Read the code, then predictTSX
                "use client";
                
                import { useState, type ReactNode } from "react";
                import Link from "next/link";
                import { useRouter } from "next/router";
                
                export default function Layout({ children }: { children: ReactNode }) {
                  const [search, setSearch] = useState("");
                  const { pathname } = useRouter();
                  return (
                    <>
                      <nav>
                        <Link href="/" aria-current={pathname === "/" ? "page" : undefined}>Teas</Link>
                        <Link href="/cart" aria-current={pathname === "/cart" ? "page" : undefined}>Cart</Link>
                        <input aria-label="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} />
                      </nav>
                      <main>{children}</main>
                    </>
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7The visitor is on / (App Router) and clicks a link to /teas/mint (Pages Router). What happens?

                Choose an answer to see the explanation.

              7. Question 7 of 7Which pages are the best first moves?

                Choose an answer to see the explanation.

              Key takeaways

              • app and pages run in one build, with a route table each, so a migration can move one route at a time.
              • An address belongs to one router. Add the new page.tsx and delete the old file together, or the build stops.
              • getStaticProps becomes an async Server Component with "use cache"; getServerSideProps becomes request-time APIs inside Suspense.
              • The root layout does the jobs of _app and _document for App Router pages, and metadata replaces next/head. Shared components with state or router hooks need "use client" and next/navigation; static ones need neither.
              • A link between the routers is a full page load. Move leaf pages first, move linked pages together, and keep what works in pages.

              Remember the one-liner.
              Move one route at a time: one address, one router, and a full page load whenever a link crosses between them.

              Read it in Next.js's own code

              This link points at release v16.4.3. In lines 2113 to 2160 of crates/next-api/src/project.rs, Turbopack lists the App Router's routes first, then adds each Pages Router route. When a path is already in the list, it reports App Router and Pages Router both match path, the error the conflicting build printed.

              Coming next: Under the hood, the course's last stage, starting with a tour of the Next.js repository. It reads the source of the server, the build and both routers.

              CompleteFrontend Clear concepts. Working examples.