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.
- 01Run 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.
- 02Move 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.
- 03Plan 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:
app/cart/page.tsx1import { Suspense } from "react";2import { cookies, headers } from "next/headers";3import { getTea } from "@/lib/teas";4 5export const metadata = { title: "Your cart" };6 7async function Cart() {8 const tea = await getTea((await cookies()).get("cart")?.value ?? "");9 const french = (await headers()).get("accept-language")?.startsWith("fr") ?? false;10 return (11 <>12 <h1>{french ? "Bonjour" : "Hello"}, here is your cart</h1>13 <p>{tea ? `In your cart: ${tea.name}` : "Your cart is empty."}</p>14 </>15 );16}17 18export default function CartPage() {19 return (20 <Suspense fallback={<p>Loading your cart…</p>}>21 <Cart />22 </Suspense>23 );24}$ 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 contentapp/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.
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.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
pagesfolder and the Pages Router - In real life: The new building next door
- In Next.js: The
appfolder 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
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:
app/layout.tsx1import "@/styles/globals.css";2import type { Metadata } from "next";3import Layout from "@/components/layout";4 5export const metadata: Metadata = {6 title: { default: "Tea shop", template: "%s | Tea shop" },7};8 9export default function RootLayout({ children }: LayoutProps<"/">) {10 return (11 <html lang="en">12 <body className="tea-shop">13 <Layout>{children}</Layout>14 </body>15 </html>16 );17}Each step was taken in real Chrome against next start. Pick one to see the page after it.
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
GET/cart200a whole pageGET/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.
Moving one route at a time
Moving the cart took three steps, done together in one change:
- Write
app/cart/page.tsx, with the same address aspages/cart.tsx: the foldercartand apage.tsxinside it. - Replace what the Pages Router did for it: the data function, and anything
_appornext/headgave the page. - Delete
pages/cart.tsx.
The last step can't wait. Here is the shop with both cart files:
pages/cart.tsx1import type { GetServerSidePropsContext } from "next";2import { getTea } from "@/lib/teas";3 4export async function getServerSideProps({ req }: GetServerSidePropsContext) {5 const tea = await getTea(req.cookies.cart ?? "");6 const french = req.headers["accept-language"]?.startsWith("fr") ?? false;7 return { props: { greeting: french ? "Bonjour" : "Hello", item: tea?.name ?? null } };8}9 10type CartProps = { greeting: string; item: string | null };11 12export default function CartPage({ greeting, item }: CartProps) {13 return (14 <>15 <h1>{greeting}, here is your cart</h1>16 <p>{item ? `In your cart: ${item}` : "Your cart is empty."}</p>17 </>18 );19}$ 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 framesTurbopack 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.
pages/index.tsx1import Head from "next/head";2import Link from "next/link";3import { getTeas, type Tea } from "@/lib/teas";4 5export async function getStaticProps() {6 const teas = await getTeas();7 return { props: { teas } };8}9 10export default function Home({ teas }: { teas: Tea[] }) {11 return (12 <>13 <Head>14 <title>Tea shop</title>15 </Head>16 <h1>Tea shop</h1>17 <ul>18 {teas.map((tea) => (19 <li key={tea.id}>20 <Link href={`/teas/${tea.id}`}>{tea.name}</Link>21 </li>22 ))}23 </ul>24 </>25 );26}$ 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 bytesLines 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.
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("/").
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>
</>
);
}
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:
| In pages | In 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 JSON | No 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:
app/layout.tsx1export const metadata = { title: "Tea shop" };2 3export default function RootLayout({ children }: LayoutProps<"/">) {4 return (5 <html lang="en">6 <body>{children}</body>7 </html>8 );9}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.
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.
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:
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_appcould 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_documentwrote. - Line 13 renders the shop's
Layout, as_appdoes. 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 thatapp/cart/page.tsx'stitle: "Your cart"fills. The recorded cart's title isYour cart | Tea shop.
"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.
_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.From next/router to next/navigation
The two routers have different hooks. The Pages Router's useRouter comes from next/router; the App Router's comes from next/navigation, with usePathname and useSearchParams beside it. Shared code shows the difference fast. Here is the shop's layout written the Pages Router way:
components/layout.tsx1"use client";2 3import { useState, type ReactNode } from "react";4import Link from "next/link";5import { useRouter } from "next/router";6 7export default function Layout({ children }: { children: ReactNode }) {8 const [search, setSearch] = useState("");9 const { pathname } = useRouter();10 return (11 <>12 <nav>13 <Link href="/" aria-current={pathname === "/" ? "page" : undefined}>Teas</Link>14 <Link href="/cart" aria-current={pathname === "/cart" ? "page" : undefined}>Cart</Link>15 <input aria-label="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} />16 </nav>17 <main>{children}</main>18 </>19 );20}$ 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 ...Error occurred prerendering page "/_not-found". Read more: https://nextjs.org/docs/messages/prerender-errorError: NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted at <unknown> (components/layout.tsx:9:24) 7 | export default function Layout({ children }: { children: ReactNode }) { 8 | const [search, setSearch] = useState("");> 9 | const { pathname } = useRouter(); | ^ 10 | return ( 11 | <> 12 | <nav> { digest: '2311215565'}Export encountered an error on /_not-found/page: /_not-found, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullLine 9 of components/layout.tsx reads pathname from next/router's useRouter. In pages, that works. But the root layout renders this component too, and when the build prerendered /_not-found, an App Router page, it stopped: NextRouter was not mounted, pointing at line 9. The Pages Router's router doesn't exist in the App Router. usePathname from next/navigation, as in the version above, works in both: the docs say it may return null in a Pages Router page before the router is ready, and in a project with both folders its type says so. For a component that needs the old useRouter, the docs offer next/compat/router, whose useRouter returns null in the App Router.
Each row is one thing code read from the Pages Router's router, and what an App Router component uses:
| With next/router | With next/navigation |
|---|---|
router.pathname | usePathname(), which also works in a Pages Router page (and may be null there). |
router.query | useSearchParams() for ?…, and useParams() for [id]. |
router.push, replace, back | The same names on useRouter() from next/navigation. |
router.events | Gone. An Effect that reads usePathname() and useSearchParams() runs after each navigation. |
router.isFallback | Gone: there's no fallback. A Suspense fallback shows instead. |
router.asPath, isReady, locale | Gone from the App Router's useRouter. |
router.events is gone
Pages Router code often listened to router.events, like routeChangeComplete, to count page views. The App Router has no events. Its docs compose the hooks instead: an Effect that depends on the path and the search params. It isn't a one-for-one replacement: it also runs when the component first mounts, it runs after the new page is on screen, so it can't stand in for routeChangeStart, and it doesn't run for a navigation that keeps the same path and search params. For counting page views, the first run is the first page view, which is what you want.
app/navigation-events.tsx1"use client";2 3import { useEffect } from "react";4import { usePathname, useSearchParams } from "next/navigation";5 6export default function NavigationEvents() {7 const pathname = usePathname();8 const searchParams = useSearchParams();9 10 useEffect(() => {11 // In an app with both routers, these hooks' types include null.12 const query = searchParams?.toString();13 console.log(`page view: ${pathname}${query ? `?${query}` : ""}`);14 }, [pathname, searchParams]);15 16 return null;17}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Your cart is empty.
- Focus
- the link “Cart”
- Scrolled
- 0 px down
Everything Chrome's console printed during this step (1)
[log] page view: /cart
Requests the page made during this step (1), without the files it loaded
GET/cart200text/x-component- rsc
- 1
- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22cart%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2C%22refetch%22%2C98%5D%7D%2Cnull%2Cnull%2C82%5D
- next-url
- /
In app/navigation-events.tsx, lines 7 and 8 read the path and the search params, and the Effect on lines 10 to 14 logs a page view whenever either changes. The root layout renders it inside Suspense on lines 16 to 18 of app/layout.tsx: the docs explain that a component reading useSearchParams is rendered in the browser up to the closest Suspense. The Steps tab shows what Chrome's console printed: page view: /, then page view: /cart after the click on Cart, then page view: / again. Line 12 has a ?., because in a project with both routers useSearchParams may return null, and its type says so. The docs' own example, written for an App Router project, calls searchParams.toString() directly. In this shop, that line stops the build:
app/navigation-events.tsx1"use client";2 3import { useEffect } from "react";4import { usePathname, useSearchParams } from "next/navigation";5 6export default function NavigationEvents() {7 const pathname = usePathname();8 const searchParams = useSearchParams();9 10 useEffect(() => {11 const query = searchParams.toString();12 console.log(`page view: ${pathname}${query ? `?${query}` : ""}`);13 }, [pathname, searchParams]);14 15 return null;16}$ 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' } app/navigation-events.tsx(11,19): error TS18047: 'searchParams' is possibly 'null'.Failed to type check.TypeScript stopped at line 11, column 19: 'searchParams' is possibly 'null'. The same file with ?., below, builds.
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
export default function NavigationEvents() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// In an app with both routers, these hooks' types include null.
const query = searchParams?.toString();
console.log(`page view: ${pathname}${query ? `?${query}` : ""}`);
}, [pathname, searchParams]);
return null;
}
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.
/, then /cart, then /teas/mintafter step 1Teas Cart
Tea shop
Green tea
Mint tea
Lemon tea
React is running: the page responds to clicks.
The page isn't complete yet.
/ now comes from app/page.tsx. The browser loads it as a whole page, and the root layout's NavigationEvents logs page view: /.
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, andnext/navigation'susePathnamefor 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
pagesas long as you like.
- 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.
Sort each part of the tea shop by when it should move to the App Router.
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
useRouterstops the build: NextRouter was not mounted.usePathnamefromnext/navigationworks 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.
These are the shop's files after the first move. Which router answers /cart?
app/
cart/
page.tsx
layout.tsx
pages/
teas/
[id].tsx
_app.tsx
index.tsxThe App Router: app/cart/page.tsx makes /cart. The recorded build lists ◐ /cart under Route (app), and / and the tea pages under Route (pages).
You add app/cart/page.tsx, and this pages/cart.tsx is still there. What does next build do?
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>
</>
);
}
The build fails: App Router and Pages Router both match path: /cart. Delete pages/cart.tsx in the same change that adds app/cart/page.tsx.
Write app/page.tsx for this home page. What replaces getStaticProps, so the page stays stored like it was?
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>
</>
);
}
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>
</>
);
}
An async Server Component with "use cache" and cacheLife("max"). The page awaits getTeas() on line 8; the build prints ○ / 30d 1y.
The root layout renders this layout, and the build stops with NextRouter was not mounted. Which hook should line 9 use?
"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>
</>
);
}
"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>
</>
);
}
usePathname() from next/navigation on line 9 (and its import on line 5). It works in both routers, so _app and the root layout can render the same Layout.
This is app/page.tsx, and the tea pages are still in pages. A visitor clicks Mint tea. What does the browser load?
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>
</>
);
}
A full page load. The App Router asked for /teas/mint as an RSC payload, got HTML back, and loaded the page as a new document. The search box's text starts over.
This Pages Router code logs a page view after each navigation. What does the same job in the App Router?
// 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]);"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
export default function NavigationEvents() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// In an app with both routers, these hooks' types include null.
const query = searchParams?.toString();
console.log(`page view: ${pathname}${query ? `?${query}` : ""}`);
}, [pathname, searchParams]);
return null;
}
An Effect that depends on usePathname() and useSearchParams(), in a Client Component the root layout renders inside Suspense. The recorded console printed a page view after each App Router navigation.
Check your understanding
For each question, ask which router answers the address, and what that router needs.
Question 1 of 7A project has
pages/index.tsxandapp/cart/page.tsx. Which router answers each address?Choose an answer to see the explanation.
Question 2 of 7You add
app/cart/page.tsxand haven't deletedpages/cart.tsxyet. What happens?Choose an answer to see the explanation.
Question 3 of 7This home page replaced one with
getStaticProps. Where did the props go?Read the code, then predictTSXimport 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.
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 predictTSXexport 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.
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.
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.
Question 7 of 7Which pages are the best first moves?
Choose an answer to see the explanation.
Key takeaways
appandpagesrun 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.tsxand delete the old file together, or the build stops. getStaticPropsbecomes an async Server Component with"use cache";getServerSidePropsbecomes request-time APIs insideSuspense.- The root layout does the jobs of
_appand_documentfor App Router pages, and metadata replacesnext/head. Shared components with state or router hooks need"use client"andnext/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.
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.