Next.js8 min read
Next.js App Router vs Pages Router: Migration Guide
Next.js App Router vs Pages Router explained: the real differences, when to migrate, and a step-by-step migration guide updated for Next.js 16.4.
The Pages Router is the original Next.js routing system: files in pages/, data loaded through getServerSideProps or getStaticProps, and every page is a Client Component. The App Router lives in app/, is built on React Server Components, and adds nested layouts, streaming, Server Actions and the Cache Components caching model. Both still work in Next.js 16.4, but every new framework feature lands in the App Router, so migrating is worth planning even if you do it one route at a time.
I'll cover the practical differences first, then walk through the migration the way I would run it on a real codebase.
Key takeaways
- The Pages Router is not deprecated, but it is in maintenance mode. Cache Components, Server Actions, the Metadata API and Partial Prefetching are App Router only.
app/andpages/can coexist, so you can migrate route by route and ship each one._appand_documentbecome a single rootlayout.tsx.next/headbecomes themetadataexport.getServerSideProps,getStaticPropsandgetStaticPathsare replaced by async Server Components,'use cache'andgenerateStaticParams.- Since Next.js 15,
params,searchParams,cookies()andheaders()are async. Next.js 16 removed the temporary sync access. - New apps from
create-next-apphave Cache Components on by default since 16.4, so migrate toward that model, not the old implicit caching.
App Router vs Pages Router: the real differences
The file structure change is the least interesting part. The rendering model is what changes how you write code.
| Concern | Pages Router (pages/) |
App Router (app/) |
|---|---|---|
| Default component type | Client Component | Server Component |
| Data fetching | getServerSideProps, getStaticProps |
async components, 'use cache' |
| Dynamic paths | getStaticPaths |
generateStaticParams |
| Layouts | _app plus the getLayout pattern |
Nested layout.tsx files |
| Head and SEO | next/head |
metadata / generateMetadata |
| API endpoints | pages/api/* |
route.ts Route Handlers |
| Mutations | API route plus client fetch |
Server Actions |
| Loading and errors | Manual | loading.tsx, error.tsx, not-found.tsx |
| Router hooks | next/router |
next/navigation |
| Streaming | No | Yes, via Suspense |
The biggest mental shift is that components run on the server unless you add 'use client'. That means less JavaScript in the browser, direct database access in components, and no more passing a giant props object from one data function down the whole tree. If Server Components are still fuzzy for you, read React Server Components explained before you start.
Should you migrate from Pages Router to App Router?
Migrate when at least one of these is true:
- You want streaming, nested layouts or Server Actions.
- You plan to stay on Next.js for years. The Next.js 16.4 release post says Cache Components will become the default in Next.js 17, and that model only exists in
app/. - You are already fighting
getLayouthacks, prop drilling fromgetServerSideProps, or a large client bundle.
Hold off if the app is stable, rarely changes, and nobody has time to learn the new model. A half-migrated codebase that nobody understands is worse than a working Pages Router app.
Step 1: Upgrade Next.js and fix async request APIs
Get onto the latest version first, while still on the Pages Router. Next.js 16 requires Node.js 20.9 or newer and TypeScript 5.1 or newer.
npx @next/codemod@canary upgrade latestThe codemod handles most mechanical changes. On 16.4 there is also an agent-driven path (npx next@canary upgrade --agent=latest) if you use a coding agent. Things to check by hand afterwards:
- Turbopack is the default bundler. If you have a custom webpack config, run
next build --webpackuntil you port it. middleware.tsis deprecated in favor ofproxy.tswith aproxyexport.next lintis gone. Run ESLint or Biome directly.serverRuntimeConfigandpublicRuntimeConfigare removed. Use environment variables.
Step 2: Create the root layout
Create app/layout.tsx. It replaces both _app.tsx and _document.tsx for routes inside app/.
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: 'Acme', template: '%s | Acme' },
description: 'Acme storefront',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}Keep _app and _document while you migrate. Styles in the root layout do not apply to pages/*, and deleting _app early breaks every route that has not moved yet.
Context providers (theme, React Query, auth client) need a Client Component wrapper:
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/components/theme'
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>
}Then render <Providers>{children}</Providers> inside <body>.
Step 3: Move one page, client-first
The lowest-risk path from the official migration guide: move the old page component into a Client Component as-is, then wrap it in a new Server Component page that does the data fetching.
// app/dashboard/dashboard-view.tsx
'use client'
type Project = { id: string; name: string }
export function DashboardView({ projects }: { projects: Project[] }) {
// Old pages/dashboard.tsx body, unchanged: useState, useEffect, handlers
return (
<ul>
{projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}// app/dashboard/page.tsx
import { Suspense } from 'react'
import { DashboardView } from './dashboard-view'
import { getProjects } from '@/lib/data'
export default function Page() {
return (
<Suspense fallback={<p>Loading projects...</p>}>
<Projects />
</Suspense>
)
}
async function Projects() {
const projects = await getProjects()
return <DashboardView projects={projects} />
}Delete pages/dashboard.tsx in the same commit. Two files resolving to the same URL is a build error.
Once it works, shrink the client part. Move static markup up into the server page and keep 'use client' only on the interactive leaves. That is where the bundle savings come from.
Step 4: Replace getServerSideProps, getStaticProps and getStaticPaths
This is where most people copy outdated advice. Two caching models exist in Next.js 16, and the right replacement depends on which one you use.
With Cache Components (recommended for new work)
Enable it in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfigNow everything is request-time by default, and you opt into caching explicitly:
// app/blog/[slug]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { cacheLife, cacheTag } from 'next/cache'
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug })) // must return at least one
}
async function getPost(slug: string) {
'use cache'
cacheLife('hours') // replaces getStaticProps + revalidate: 3600
cacheTag(`post:${slug}`)
return db.post.findUnique({ where: { slug } })
}
export default function Page({ params }: PageProps<'/blog/[slug]'>) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Post params={params} />
</Suspense>
)
}
async function Post({ params }: Pick<PageProps<'/blog/[slug]'>, 'params'>) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.title}</article>
}PageProps<'/blog/[slug]'> is a globally available type helper generated by Next.js, so you get typed params without writing the Promise<{ slug: string }> type by hand.
Mapping table
| Pages Router | App Router with Cache Components |
|---|---|
getServerSideProps |
Async Server Component, no cache directive, wrapped in <Suspense> |
getStaticProps |
'use cache' with cacheLife('max') |
getStaticProps + revalidate: 60 |
'use cache' with cacheLife('minutes') or a custom profile |
getStaticPaths |
generateStaticParams |
fallback: false |
Call notFound() for unknown params (dynamicParams is not supported) |
res.revalidate() on-demand ISR |
revalidateTag(tag, 'max') or revalidatePath(path) |
req.cookies |
await cookies() from next/headers |
If you stay on the previous model (no cacheComponents flag), fetch is not cached by default since Next.js 15. You opt in with cache: 'force-cache' or next: { revalidate: 60 }, and route segment configs like export const revalidate still work. Some older tutorials claim fetch is cached by default. That was Next.js 13 and 14 behavior.
The full set of caching options is covered in Next.js caching explained.
Step 5: Swap router hooks and API routes
next/router does not work in app/. Use the hooks from next/navigation:
'use client'
import { useRouter, usePathname, useSearchParams, useParams } from 'next/navigation'
export function Filters() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const params = useParams<{ slug: string }>()
function setSort(sort: string) {
const next = new URLSearchParams(searchParams)
next.set('sort', sort)
router.push(`${pathname}?${next}`)
}
return <button onClick={() => setSort('price')}>Sort by price ({params.slug})</button>
}What changed: router.query is split into useSearchParams and useParams, router.pathname is now usePathname, and asPath, isReady, isFallback and locale are gone. Components that use useSearchParams need a <Suspense> boundary above them. For shared components used by both routers during the transition, useRouter from next/compat/router works in both.
API routes in pages/api keep working. When you move them, a Route Handler uses Web Request and Response:
// app/api/projects/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const projects = await getProjects(searchParams.get('team'))
return Response.json(projects)
}Many API routes disappear entirely during migration. If the only caller was your own frontend, a Server Component can read the data directly and a Server Action can handle the mutation. See the Next.js Server Actions guide for that pattern.
Common App Router migration mistakes
- Adding
'use client'to every file. It works, but you keep the Pages Router bundle size and lose the main benefit. Push it down to interactive leaves. - Awaiting
cookies()orparamsat the top of a layout. With Cache Components, this blocks the static shell for everything below. Read request data inside a component wrapped in<Suspense>. - Leaving
export const dynamic = 'force-dynamic'around. With Cache Components on,dynamic,revalidateandfetchCacheexports error. Remove them and use'use cache'or<Suspense>. - Forgetting state now persists. With Cache Components, routes are hidden with React's
<Activity>instead of unmounted, so open dropdowns anduseActionStateresults can survive a back navigation. - Migrating unrelated routes in random order. Links between
app/andpages/are hard navigations without prefetching. Move route groups that link to each other together. - Using
Date.now()orMath.random()in a prerendered component. Cache Components fails the build. Callawait connection()inside a Suspense boundary first, or move the call into a Client Component.
FAQ
Is the Pages Router deprecated in Next.js 16?
No. The Pages Router still ships and works in Next.js 16. New features such as Cache Components, Server Actions and the Metadata API are App Router only, so new projects should start on the App Router.
Can I use the App Router and Pages Router in the same project?
Yes. The app and pages directories can live side by side, which is how incremental migration works. Navigating between a route in app and a route in pages causes a hard navigation, so migrate related routes together.
What replaces getServerSideProps and getStaticProps in the App Router?
Async Server Components that fetch data directly. With Cache Components enabled, data is request-time by default and you opt into caching with the 'use cache' directive and cacheLife, which replaces both getStaticProps and revalidate.
What replaces getStaticPaths in the App Router?
generateStaticParams. It returns an array of param objects. With Cache Components enabled it must return at least one entry, and dynamicParams is no longer supported, so call notFound() for invalid params instead.
How long does an App Router migration take?
It depends on how much client-side state and getServerSideProps logic you have. Because both routers run together, you can migrate one route at a time and ship each step, so the work does not need a big-bang release.
Wrapping up
Treat the move from Pages Router to App Router as a series of small, shippable steps: upgrade, add the root layout, move one route with a client wrapper, then convert data fetching to 'use cache' and Suspense. The official App Router migration guide and the Cache Components migration guide are worth keeping open while you work. Take the time to learn the server-first model properly and the rest of the migration is mostly mechanical.
- #nextjs
- #app-router
- #pages-router
- #migration
- #react
Keep reading
Next.js8 min read
Next.js Authentication Guide: Sessions, proxy.ts, DAL
Next.js authentication guide for Next.js 16: pick an auth library, manage sessions, protect routes with proxy.ts and secure Server Components and Actions.
Read articleNext.js9 min read
Next.js i18n Guide: App Router, Routing and hreflang
Next.js internationalization with the App Router: locale routing in Proxy, server-side dictionaries, hreflang and a localized sitemap, with English and Nepali.
Read articleNext.js7 min read
Next.js SEO Guide: Metadata, Sitemap, JSON-LD, OG
A practical Next.js SEO guide for Next.js 16: Metadata API, generateMetadata, canonical URLs, sitemap.ts, robots.ts, JSON-LD and dynamic OG images.
Read article