Migrate Next.js Pages Router to App Router: A Buyer's Guide
By Nayan Kyada · · 7 min read
Part of The Sanity + Next.js Guide
If you need to migrate a Next.js Pages Router site to the App Router, the good news is that you almost never have to do it all at once. Next.js lets both routers live in the same project, so a Sanity-powered marketing site can move one route at a time while staying live and ranking. The real questions are whether it is worth doing, in what order, and what can go wrong with search traffic.
If you are coming from Gatsby instead, that is a different path, covered in migrating Gatsby to the Next.js App Router.
When the migration is worth it
The Pages Router is not broken. A site on it that is fast, stable and easy for editors to use does not need a rewrite for its own sake. I would migrate when one or more of these is true:
- Client JavaScript is heavy because every page hydrates in full, and Core Web Vitals (especially INP) suffer for it.
- You want streaming, Suspense boundaries, or partial prerendering, which only exist in the App Router model.
- New features keep fighting the old structure: nested layouts, per-section loading states, or draft preview done awkwardly with the older preview mode.
- Hiring is getting harder. Most developers now learn the App Router first, and library docs have moved that way.
- You are about to do a redesign or CMS change anyway, so the routing work rides along with work you are already paying for.
I would not migrate just because a blog post said you should. If the site earns money and nothing above applies, leave it alone and put the budget into content or a performance and Sanity audit, which starts from $800 and tells you what is actually slow.
The incremental route-by-route strategy
The safe approach is boring on purpose. You add an app directory next to pages, move routes across in order of risk, and never change a URL.
- Upgrade first, migrate second. Get to Next.js 16 and React 19 on the existing Pages Router code. Fix upgrade breakage separately so you know what caused what.
- Create the root layout. The
app/layout.tsxfile replaces_appand_document. Move fonts, global CSS and analytics there. - Move low-risk routes first. A privacy policy, a contact page, a 404. These prove the layout, styling and metadata setup without risking revenue pages.
- Move templated content next. Blog posts and landing pages built from Sanity documents. One template covers hundreds of URLs, so this is where the payoff is.
- Move complex routes last. Search, forms, authenticated areas, anything with heavy client state.
- Delete the old file the moment the new one ships. A URL cannot be served by both routers, and the build will complain if you forget.
Each step is a separate deploy with its own staging URL. If a route misbehaves, you revert one file, not a quarter of work.
Data fetching changes with Sanity
This is where most of the actual rewriting happens. In the Pages Router you fetch in getStaticProps and pass props down. In the App Router, a server component fetches directly and the data never has to be serialised into page props.
Before:
// pages/blog/[slug].tsx
import type { GetStaticProps, GetStaticPaths } from 'next'
import { client } from '@/sanity/client'
const query = `*[_type == "post" && slug.current == $slug][0]{title, body, publishedAt}`
export const getStaticPaths: GetStaticPaths = async () => {
const slugs: string[] = await client.fetch(`*[_type == "post"].slug.current`)
return { paths: slugs.map((slug) => ({ params: { slug } })), fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await client.fetch(query, { slug: params?.slug })
if (!post) return { notFound: true }
return { props: { post }, revalidate: 60 }
}After:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '@/sanity/client'
const query = `*[_type == "post" && slug.current == $slug][0]{title, body, publishedAt}`
export async function generateStaticParams() {
const slugs: string[] = await client.fetch(`*[_type == "post"].slug.current`)
return slugs.map((slug) => ({ slug }))
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await client.fetch(query, { slug }, { next: { tags: ['post'] } })
if (!post) notFound()
return <article>{/* render post */}</article>
}Three things change that clients should know about. First, params is now a promise in current versions, so every dynamic route needs an await. Second, time-based revalidate is replaced by tag or path revalidation, which pairs well with Sanity webhooks; see Sanity webhooks with ISR revalidation. Third, draft preview moves to Draft Mode, covered in Sanity Live Preview in the App Router. Editors will notice preview behaviour, so test it with them, not just with a developer.
GROQ queries themselves usually do not change at all. That is the nice part: the content layer stays put while the rendering layer is replaced.
SEO risks: metadata and canonicals
Search traffic is the thing a migration can actually lose. The risks are specific and checkable.
- Metadata.
next/headdoes not work in the App Router. Each route needsgenerateMetadataor a staticmetadataexport. A route that moves without one ships with no title or description, and nobody notices until rankings slip. - Canonicals. Set
alternates.canonicalexplicitly per route. Do not rely on defaults, especially on paginated or filtered pages, and keep trailing-slash behaviour the same as before. - Structured data. JSON-LD that lived in a head component has to be rendered in the page body now. See JSON-LD in Next.js.
- Redirects and status codes. Missing documents must return a real 404 through
notFound(), not a 200 with an error message. - Sitemap and robots. Move these to the
sitemap.tsandrobots.tsfile conventions and diff the output against the old files.
A simple generateMetadata looks like this:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await client.fetch(
`*[_type == "post" && slug.current == $slug][0]{title, seoDescription}`,
{ slug }
)
return {
title: post?.title,
description: post?.seoDescription,
alternates: { canonical: `/blog/${slug}` },
}
}Before each route goes live, crawl staging and production and compare title, description, canonical, robots meta, H1 and structured data. A spreadsheet diff catches nearly everything.
Effort ranges by site size
These are rough ranges from general practice, not quotes. Complexity matters more than page count, because a hundred posts on one template is cheap and ten bespoke interactive pages is not.
| Site size | Typical scope | Effort |
|---|---|---|
| Small (5 to 15 routes) | Marketing pages, a blog, a contact form | 1 to 2 weeks |
| Medium (15 to 60 routes) | Several templates, Sanity-driven pages, preview, redirects | 3 to 6 weeks |
| Large (60+ routes or app features) | Search, auth, i18n, custom API routes, heavy client state | 2 to 4 months, often phased |
Things that push you up: internationalised routing, a lot of getServerSideProps logic, API routes that should become route handlers, a UI library that does not support server components, and no tests. Things that push you down: a clean Sanity schema, TypeScript throughout, and a staging environment that already exists.
What to ask a developer
- Will every URL stay identical, and how will you prove it?
- How will you compare metadata and canonicals before and after each route?
- Which routes move first, and why those?
- How does draft preview work for editors afterwards?
- Can we deploy and roll back route by route?
- What do you do about client-side libraries that break in server components?
A good answer is specific and mentions crawling staging. A vague answer about modernising the stack is a red flag.
Work I do
This is work I do: incremental App Router migrations on Sanity and Next.js sites, with an SEO diff on every route. If your site needs a rebuild alongside it, see my Sanity and Next.js build and migration packages, or start with an audit to size the job.
Frequently asked questions
01Do I have to migrate from Pages Router to App Router?
No. The Pages Router still works and you can run both routers in one project. Migrate when the Pages Router is blocking something you need, such as streaming, smaller client bundles, or hiring and maintaining a modern codebase.
02Can I migrate to App Router one page at a time?
Yes. Both routers can live in the same Next.js project, so you move one route at a time. A given URL can only be served by one router, so you delete the pages file when its app equivalent ships.
03Will migrating to App Router hurt my SEO?
It does not have to. Rankings only drop when URLs, titles, canonicals, structured data or rendered content change by accident. Keep URLs identical, port metadata deliberately, and compare crawled output before and after each route moves.