
Migrate Next.js Pages Router to App Router Without Breaking Everything
A detailed Pages Router to App Router migration guide covering layouts, route structure, Server Components, caching, metadata, forms, auth, testing, and a safe rollout.
Moving from the Pages Router to the App Router is not a file rename. The route files change, but the bigger change is where code runs, how data is fetched, and which parts of the page need browser JavaScript. You can move a real app safely if you do it route by route and keep the old router running while you learn the new one.
What actually changes
The Pages Router starts from pages, props, and data functions. The App Router starts from route segments, server-rendered components, and special files. A page or layout is a Server Component unless you opt into a Client Component. That means browser APIs, state, effects, and click handlers need a clear client boundary.
The migration map
pages/_app.tsx -> app/layout.tsx
pages/_document.tsx -> app/layout.tsx
pages/index.tsx -> app/page.tsx
pages/products/[slug].tsx -> app/products/[slug]/page.tsx
pages/404.tsx -> app/not-found.tsx
pages/_error.tsx -> app/error.tsx
pages/api/orders.ts -> app/api/orders/route.ts
getStaticPaths -> generateStaticParams
getStaticProps -> fetch + cache/revalidate
getServerSideProps -> async Server Component + request APIsNext.js lets pages/ and app/ live together. Use that instead of creating a big-bang migration branch.
A migration plan that does not get out of hand
First, make a route inventory. Write down each public page, dynamic route, API route, redirect, rewrite, auth check, metadata rule, and data source. Mark the routes that handle money, accounts, or admin work as late migration candidates. Start with a read-only route where you can compare old and new output.
# Useful inventory searches before moving anything
rg "get(ServerSideProps|StaticProps|StaticPaths|InitialProps)" pages src
rg "next/router|next/head|pages/api|_app|_document|_error" .
rg "window|document|localStorage|useEffect|useState|onClick" components srcA sensible order
1. Upgrade Next.js and make the current Pages Router build and test cleanly.
2. Add app/ with only a root layout and one low-risk route.
3. Move shared UI into components that can work from either router.
4. Move data-heavy and dynamic routes after you understand caching and request APIs.
5. Move API routes, auth boundaries, and error handling with targeted tests.
Build the root layout before migrating pages
app/layout.tsx replaces the shared parts of _app and _document. It must render html and body. Keep pages/_app and pages/_document until no Pages Router route needs them. Styles imported in app/layout.tsx do not magically style routes still served from pages/.
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
export const metadata: Metadata = {
title: { default: 'Acme', template: '%s | Acme' },
description: 'A short default description',
}
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Context providers need to be Client Components because they use React context. Keep that boundary small. The layout itself can stay on the server and render a client Providers component around the part that needs it.
// app/providers.tsx
'use client'
import { ThemeProvider } from '@/components/theme-provider'
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>
}Get the Server and Client Component boundary right
This is where most migration surprises come from. Server Components can fetch from a database or private API and keep those details out of the client bundle. Client Components are for interactivity and browser-only APIs. Adding 'use client' to a file makes its imports and children part of the client graph, so putting it high in the tree can send far more JavaScript than intended.
// app/products/[slug]/page.tsx
import AddToCart from './add-to-cart'
import { getProduct } from '@/lib/products'
export default async function ProductPage({ params }: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCart productId={product.id} />
</main>
)
}// app/products/[slug]/add-to-cart.tsx
'use client'
import { useState } from 'react'
export default function AddToCart({ productId }: { productId: string }) {
const [pending, setPending] = useState(false)
return <button disabled={pending} onClick={() => setPending(true)}>Add to cart</button>
}Move routing hooks last, and use the compatibility hook while both routers exist
next/router is not the App Router hook. In new client components use next/navigation. During the overlap, next/compat/router helps a shared component run from either router. Do not try to replace router.events one for one. Compose usePathname and useSearchParams for UI that needs to react to navigation.
// App Router only
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
export function SearchButton() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
function setQuery(value: string) {
const params = new URLSearchParams(searchParams.toString())
params.set('q', value)
router.push(`${pathname}?${params}`)
}
return <button onClick={() => setQuery('shoes')}>Search</button>
}Replace data functions with explicit data behavior
Do not translate getServerSideProps and getStaticProps by copy-pasting their bodies. Put the data call next to the Server Component that needs it, then decide whether the result is request-time, cached, or invalidated after a mutation. The default details have changed across Next.js releases and Cache Components is an opt-in model, so read the docs for the Next.js version you run before setting route-wide cache flags.
// Similar intent to getServerSideProps
async function getViewer() {
const response = await fetch('https://api.example.com/viewer', {
cache: 'no-store',
})
if (!response.ok) throw new Error('Could not load viewer')
return response.json()
}
// Cached data with a time-based refresh
async function getCatalog() {
const response = await fetch('https://api.example.com/catalog', {
next: { revalidate: 3600, tags: ['catalog'] },
})
if (!response.ok) throw new Error('Could not load catalog')
return response.json()
}// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { searchParams } = new URL(request.url)
if (searchParams.get('secret') !== process.env.REVALIDATE_SECRET) {
return new Response('Unauthorized', { status: 401 })
}
revalidateTag('catalog', 'max')
return Response.json({ revalidated: true })
}Use the current fetch and revalidation docs for exact cache rules. For current Cache Components projects, the newer use cache and cacheLife model changes some of the older route segment advice.
Dynamic routes, params, and metadata
getStaticPaths becomes generateStaticParams. The important behavior change is what happens for a path that was not generated. dynamicParams controls that. It is true by default, which allows on-demand generation. Use false only when a missing path should really be a 404.
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
const posts = await getPublishedPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export async function generateMetadata({ params }: PageProps<'/blog/[slug]'>) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return { title: post.title, description: post.summary }
}
export default async function BlogPost({ params }: PageProps<'/blog/[slug]'>) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.title}</article>
}If you are on Next.js 15 or later, params and searchParams are asynchronous. The separate params migration note on this site covers the common page, layout, and generateMetadata mistakes.
Move next/head into metadata exports
Static title and description belong in metadata. Dynamic metadata belongs in generateMetadata. Keep the metadata function server-only. It can fetch the same resource as the page; Next.js documents that identical fetch calls in generateMetadata, layouts, pages, and generateStaticParams are automatically memoized for a render.
Use route-level loading, error, and not-found files
app/
products/
[slug]/
page.tsx # the route UI
loading.tsx # Suspense fallback while this segment loads
error.tsx # Client error boundary for this segment
not-found.tsx # local not-found UI, if needederror.tsx must be a Client Component because it needs a reset callback. Use notFound() when missing data is expected. Use an error boundary for unexpected failures. This lets a product page fail without turning the whole application into a generic error page.
API Routes can stay put until there is a reason to move them
pages/api routes keep working during the migration. Move one only when you want a Route Handler at the matching app route. Route Handlers use standard Request and Response APIs. They are a good fit for webhooks and JSON endpoints. But do not create a Route Handler just to call your own backend from a Server Component. Call the shared server-side function directly.
// app/api/orders/route.ts
import { createOrder } from '@/lib/orders'
export async function POST(request: Request) {
const input = await request.json()
const order = await createOrder(input)
return Response.json(order, { status: 201 })
}Treat auth and request APIs as a design review
getServerSideProps often hid auth checks inside req and res. In the App Router, use cookies() and headers() from next/headers in server code. Keep authorization close to the data or action that needs protecting. Middleware or proxy can help with early redirects, but it should not be the only layer that decides whether protected data is returned.
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = (await cookies()).get('session')?.value
const user = session ? await getUserFromSession(session) : null
if (!user) redirect('/login')
return <Dashboard user={user} />
}Do a styling pass before calling the route done
Import global CSS from the root layout, and make sure Tailwind scans app/ as well as pages/ while both are present. CSS Modules still work. Watch third-party UI packages too. If a package reads window during import, wrap that package in a small Client Component instead of turning the whole route into a Client Component.
If a route looks correct in development but loses styles in production, use the Next.js App Router CSS checklist before changing build settings at random.
Gotchas worth putting in the pull request description
A layout persists between navigations. Do not put page-specific reset logic in a parent layout and expect it to run on every child route change.
Crossing from a pages route to an app route is a hard navigation during the incremental phase. Do not measure that as an App Router regression.
A Client Component can render Server Components as children, but it cannot import server-only code into its own module graph.
Do not put secrets in props for a Client Component. The props cross the server-to-client boundary.
Do not assume router.refresh invalidates server data. It refreshes the current route's client view. Revalidate the data or path when the mutation needs to change shared cached content.
Test redirects, 404s, metadata, and unauthenticated requests in a production build. A happy-path page screenshot is not enough.
A release checklist
For every migrated route, compare status code, canonical URL, title, description, robots rules, Open Graph tags, structured data, and HTML content with the old route.
Run browser checks for the main screen sizes. Then test direct visits, client navigation, reloads, invalid URLs, and an expired or missing session.
Log cache misses, unexpected dynamic rendering, and server errors after release. Have a simple rollback path for each route, not only for the whole migration.
The part that makes the migration go well
The App Router works best when you treat it as a chance to reduce client code and make data behavior obvious. Move a route, draw a line around its client pieces, name its cache behavior, and test the ugly paths. Repeating that route by route is slower than a giant rewrite for the first week, but much faster than digging out of a mixed router mess later.
A deeper field guide for a real migration
The rest of this guide gets specific. Use it as a working document, not a checklist you race through. A safe migration has two jobs: keep the old product behaving as people expect, and leave the new route easier to understand than the old one. If a change does neither, do not bundle it into the router work.
Before you touch app/: make the old app measurable
Take a short baseline first. Save a production build, a route list, the current bundle report if you have one, and screenshots of the pages you are moving. For every chosen route, record the status code, title, canonical tag, key API calls, first render, logged-in state, logged-out state, and what happens when the record does not exist. This sounds boring, but it turns a future bug from a vague feeling into a comparison.
# Run these against a production-like build, not only next dev
next build && next start
# Capture the important HTML and response headers
curl -I http://localhost:3000/products/example
curl -s http://localhost:3000/products/example > /tmp/product-before.html
# Find route code that usually needs a design decision
rg "get(ServerSideProps|StaticProps|StaticPaths|InitialProps)" pages src
rg "next/router|next/head|next/script|next/image" pages src components
rg "cookies\(|headers\(|draftMode\(" .Use route groups to organize work without changing URLs
App Router folders describe the URL until you wrap a folder name in parentheses. A route group is useful when marketing pages, logged-in pages, and admin pages need different layouts but should not gain an extra URL segment. A folder starting with an underscore is private and cannot become a route. Keep shared code outside app/ or in a private folder so a helper never accidentally becomes a public path.
app/
layout.tsx
(marketing)/
layout.tsx
pricing/page.tsx # /pricing
(product)/
dashboard/layout.tsx
dashboard/page.tsx # /dashboard
_components/
account-nav.tsx # never a route
blog/
[slug]/
page.tsxA layout persists while users move between child routes. That makes it the right place for a shell, nav, and long-lived providers. If you need a subtree to remount on every navigation, use template.tsx instead. Most apps need fewer templates than they first think. Start with layouts and introduce a template only when you can name the state that must reset.
How to choose the Server and Client boundary
Ask one simple question per component: does this code need a browser event, browser API, local React state, or an effect? If the answer is no, leave it as a Server Component. Server Components are a good home for database calls, private API tokens, large dependencies, and HTML that does not need client code. A Client Component can receive a server-rendered child, but its own imports are in the browser graph.
// app/components/date-picker.tsx
'use client'
// Keep one browser-only package at the edge of the tree.
export { DatePicker } from 'some-browser-only-date-picker'
// app/orders/new/page.tsx
import { DatePicker } from '@/app/components/date-picker'
import { getOrderDefaults } from '@/lib/orders'
export default async function NewOrderPage() {
const defaults = await getOrderDefaults()
return <DatePicker timezone={defaults.timezone} />
}This wrapper pattern is also useful for analytics widgets, rich text editors, maps, drag and drop libraries, and code that reads window at import time. Do not add 'use client' to page.tsx to make one library happy. Isolate the library, pass small plain props, and keep the rest of the route on the server.
fetch, secrets, databaseDefault. Keep work and dependencies on the server.useState, click, browser APIsAdd 'use client' only at the interactive edge.Replace router APIs by intent, not by search and replace
Pages Router useRouter mixes several jobs: navigation, the current pathname, query values, route events, locale information, and readiness. App Router splits these into smaller hooks. useRouter is for push, replace, back, forward, prefetch, and refresh. usePathname gives the path. useSearchParams gives read-only query values. Use these hooks in Client Components only.
// A shared component while pages/ and app/ still coexist
'use client'
import { useRouter as useCompatRouter } from 'next/compat/router'
import { useSearchParams } from 'next/navigation'
export function PageNumber() {
const router = useCompatRouter()
const searchParams = useSearchParams()
const page = searchParams.get('page') ?? '1'
// router is null when this component renders under app/
if (router && !router.isReady) return null
return <span>Page {page}</span>
}There is no direct App Router replacement for router.events. Often the old event was used for a progress bar, analytics page view, or closing a menu. Build that behavior from pathname and searchParams changes in a small client component. For data, do not use navigation events as a cache invalidation system. Invalidate the data where the mutation happens.
Search params are input, not a hidden client store
A common Pages Router pattern is shallow routing to change filters without re-running everything. In the App Router, make the URL the input to the page and decide which parts need to read it. A page receives searchParams on the server. A small client control can update the query string. Keep filter parsing in one shared function so a pasted URL, a direct request, and a client click all mean the same thing.
// app/products/page.tsx
type Search = Promise<{ q?: string; page?: string }>
export default async function ProductsPage({ searchParams }: { searchParams: Search }) {
const { q = '', page = '1' } = await searchParams
const filters = parseProductFilters({ q, page })
const products = await getProducts(filters)
return <ProductResults products={products} filters={filters} />
}Make a cache decision before writing a fetch call
The old data functions gave the team a familiar label: static or server-side. App Router code needs a more direct statement: who can see this data, how old may it be, and what event makes it stale? Put that sentence in the pull request. A catalog may be fine for an hour. A signed-in account page is usually request-specific. Inventory may need invalidation immediately after a successful order.
Read cookies or headers where needed. Treat the route as dynamic.
Set explicit cache and revalidation rules around the data that changes.
Measure the route and write down the expected freshness before shipping.
A practical rule of thumb
Public content with a known refresh window: use an explicit revalidation policy and a tag if a CMS or admin action can update it early.
User-specific data, request cookies, or authorization checks: load it on the server for that request and keep the auth check next to the data access.
Expensive shared reads: centralize them in a server function. Avoid making a Server Component call your own Route Handler through HTTP, because that adds a round trip and loses type safety.
// app/actions/update-product.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updateProduct(id: string, input: ProductInput) {
await requireAdmin()
await saveProduct(id, input)
revalidateTag('products', 'max')
revalidatePath(`/products/${id}`)
}Cookies, headers, redirects, and request-specific work
When a route reads a cookie or header, it is making a request-specific decision. Keep that code at the smallest server boundary that needs it. Parse session data in one helper, validate authorization in the data access layer or action, and use redirect() or notFound() as a control-flow result. Do not send a protected record to the browser and hope the client hides it.
// lib/orders.ts
import 'server-only'
export async function getOrderForViewer(orderId: string) {
const viewer = await requireViewer()
const order = await db.order.findUnique({ where: { id: orderId } })
if (!order || order.accountId !== viewer.accountId) return null
return order
}
// app/orders/[id]/page.tsx
const order = await getOrderForViewer(id)
if (!order) notFound()Move forms carefully: Server Actions are not a free rewrite
Pages Router forms often post to an API route, then the client handles the result. That can keep working. Server Actions are useful when the form and mutation belong to the same app, but they still need input validation, authorization, pending UI, and an error shape people can use. Keep a Route Handler for public API clients, webhooks, mobile apps, or another frontend.
// app/settings/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const schema = z.object({ displayName: z.string().min(2).max(80) })
export async function saveSettings(_: unknown, formData: FormData) {
const input = schema.safeParse({ displayName: formData.get('displayName') })
if (!input.success) return { error: 'Use a name between 2 and 80 characters.' }
await updateSettingsForCurrentUser(input.data)
revalidatePath('/settings')
return { ok: true }
}Test a double click, slow network, expired session, invalid input, and a refresh after success. Those cases matter more than whether the form has fewer files.
Treat metadata as part of the route contract
Move static defaults to the root layout and route-specific values to metadata or generateMetadata. Check canonical URLs, robots directives, Open Graph image URLs, and titles for dynamic pages. Add sitemap.ts and robots.ts when they fit the app. A migration is a good time to remove duplicate titles and pages that accidentally return indexable 200 responses for missing records.
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }: PageProps<'/products/[slug]'>) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return { title: 'Product not found' }
return {
title: product.name,
description: product.shortDescription,
alternates: { canonical: `/products/${product.slug}` },
openGraph: { images: [product.ogImageUrl] },
}
}Test the browser flow and the server contract
Unit tests are good for helpers that parse params, validate input, and choose cache keys. They are not enough for async Server Components, redirects, streaming, metadata, or route boundaries. Add a few end-to-end checks for every migrated route family. Run them against a production build so development-only behavior does not hide a problem.
import { test, expect } from '@playwright/test'
test('missing product gives a real 404', async ({ page }) => {
const response = await page.goto('/products/does-not-exist')
expect(response?.status()).toBe(404)
await expect(page).toHaveTitle(/not found/i)
})
test('product page keeps its canonical URL', async ({ page }) => {
await page.goto('/products/example')
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
'href',
/products\/example$/,
)
})For a protected route, add: signed out direct visit, signed in direct visit, client navigation from another route, browser back, refresh, expired session, and a permission check for someone in the wrong account. For public content, add: valid slug, missing slug, legacy redirect, preview if you use it, and social metadata.
Ship one route family, then watch it
A route family might be a product list plus product detail pages, or an account settings area. Keep the release small enough that a spike in errors points to one change. Watch 404s, 500s, redirect volume, cache behavior, Web Vitals, and conversion or completion events that matter for that route. Compare search impressions and crawl errors after the migration if it is a public section.
Have a boring rollback
A rollback should be a known commit or a small routing switch, not a late-night rewrite. Keep the old Pages Router route in source control until the replacement has been live long enough for your normal traffic and cron jobs to touch it. Do not delete old tests the same day you migrate a route. Update them after the new behavior is accepted.
Things worth postponing
Parallel routes, intercepting routes, partial prerendering experiments, a full state-management rewrite, a design refresh, and a new auth provider can all be good work. Combining them with a router migration makes failures hard to explain. Move the route first. Then make one follow-up change with its own before and after test.
A realistic seven-day plan for a medium app
Day 1: upgrade, make the Pages Router build cleanly, inventory routes, and choose one read-only route.
Day 2: add the root layout, global styles, fonts, and a small provider boundary. Do not move customer-facing routes yet.
Day 3: move the chosen route, its metadata, loading state, missing-data behavior, and browser test.
Day 4: review the client bundle boundary, cache behavior, and direct server calls. Ship that route family if the comparison looks good.
Day 5: move a dynamic public route and add generateStaticParams only if it makes sense for the data.
Day 6: move one protected route or form, with authorization tests and a rollback path.
Day 7: clean up shared components, record the patterns that worked, and choose the next route family. Do not try to finish everything because the first week went well.
Final checklist for every route
Before calling a route done, check: direct load, client navigation, reload, invalid param, loading state, error state, auth state, status code, title, description, canonical, Open Graph tags, structured data if present, analytics event, cache behavior, and a production-build browser test. It is a lot, but it becomes quick when it is the same routine each time.
The end state should not just be an app/ folder. It should be a codebase where server work is obvious, browser-only code is small, data freshness has a named rule, and each route has a test for the bad path as well as the happy path.
Sources and further reading
- How to migrate from Pages to the App Router · Next.js
- Server and Client Components · Next.js
- generateMetadata · Next.js
- Next.js fetch API · Next.js
- Caching and revalidating · Next.js
- Error handling · Next.js
- How to update data with forms · Next.js
- How to implement authentication in Next.js · Next.js
- How to set up testing in Next.js · Next.js