The coexistence model, page-by-page migration strategy, getServerSideProps→RSC, getStaticProps→generateStaticParams, API Routes→Route Handlers, next/router→next/navigation API differences, _app/_document→RootLayout providers, Middleware behaviour during partial migration, and the ten pitfalls that block every team mid-migration.
P-16 — Pages Router to App Router Migration
Who this is for: Engineers with an existing Next.js codebase running on the Pages Router who need to migrate to the App Router without stopping feature development, without a big-bang rewrite, and without breaking production. This module is written from the experience of doing this migration on a real production application — a 47-route codebase migrated over three months while shipping features every week. Every pitfall listed here is a real incident.
Why Migration Is Not a Rewrite
The single most important thing to understand about this migration: you do not start over. You do not create a new repository. You do not freeze feature development for two months while you port everything. The App Router and the Pages Router coexist in the same Next.js project simultaneously, and that coexistence is the entire strategy.
Next.js has supported both routers in the same project since version 13. The framework routes requests differently depending on where the file lives: if a route exists in app/, it gets the App Router. If it exists only in pages/, it gets the Pages Router. If a route exists in both — app/ wins. That precedence rule is the foundation of your migration plan.
Here is what this means in practice:
- Your existing
pages/directory stays exactly as it is. No changes required to start. - You create an
app/directory and add routes to it one at a time. - Every route you add to
app/takes precedence over the correspondingpages/route automatically. - Routes you haven't migrated yet continue to be served from
pages/unchanged. - Your team keeps shipping features in
pages/the entire time.
This is a migration strategy, not a rewrite strategy. The timeline is weeks or months, not days. A codebase with 50 routes will not be migrated in a single sprint, and that is fine.
The Leaf-First Strategy
Not all routes are equal difficulty to migrate. The correct sequencing is leaf-first: start with routes that have no shared layout dependencies, no complex global state requirements, and no tight coupling to _app.tsx wrapping logic.
Leaf routes are your standalone pages — /about, /terms, /privacy, /404, /500. These pages typically have:
- No per-page authentication checks
- No layout wrappers beyond the global header/footer
- Simple or no data fetching
- No dependency on global context (cart state, user session display)
Migrate these first. They give your team experience with App Router patterns in low-risk territory. You will make mistakes — an accidental next/router import, a missing global CSS import, a wrong cache config. Make those mistakes on your terms and conditions page, not your checkout flow.
After leaf routes, work inward toward complexity:
- Public content pages — blog, marketing pages with
getStaticProps - API routes — migrate
pages/api/toapp/api/route handlers incrementally - Authenticated leaf pages — single pages that require login but have simple data needs
- Authenticated nested pages — dashboard sections, settings pages, multi-level layouts
- The core authenticated layout — the most complex, migrate last
The dashboard and authenticated route tree are last because they typically require migrating auth context, shared layouts, and multiple interconnected pages simultaneously. Do not start there.
The Coexistence Model — What Works and What Doesn't
Running both routers simultaneously is well-supported but has real constraints. Understanding the boundary between the two trees prevents you from spending hours debugging a problem that has a two-sentence explanation.
What Works
Both routers active, serving different routes. There is no configuration toggle. If app/ exists, it runs. If pages/ exists, it runs. Next.js handles the routing split internally.
Middleware runs once and applies to both. Your middleware.ts at the project root applies to requests matched by either router. The matcher config in the Middleware's exported config object is path-based, not router-based — it does not know or care whether a matched path is in app/ or pages/.
next/link navigates between both. Client-side navigation with <Link> works seamlessly across the router boundary. You can link from an app/ page to a pages/ page and vice versa without anything special.
pages/api/ routes remain active. Your existing API routes do not need to be migrated before you migrate pages. Keep them in pages/api/ until you're ready. App Router pages can fetch from pages/api/ routes with no issues.
next/headers and next/cookies work in app/. These are App Router only but don't interfere with pages/ routes — they simply don't exist in the Pages Router context.
What Does Not Work — The Gotchas
You cannot share a React Context provider between pages/ and app/. This is the one that breaks teams most often. The Pages Router tree and the App Router tree are two separate React trees. A Context.Provider in _app.tsx does not extend into app/ routes. A Context.Provider in app/layout.tsx does not extend into pages/ routes.
This has direct consequences for any global state you rely on: auth context, cart context, theme context, notification context. If your _app.tsx wraps everything in <AuthContext.Provider> and your pages read from useAuth(), that pattern breaks the moment you migrate a page to app/. The migrated page is in a different tree. It cannot reach the _app.tsx provider.
Your options during the coexistence period:
- Duplicate the provider in both
_app.tsxandapp/layout.tsx(messy but functional) - Move the shared state to cookies or URL state that both trees can read independently
- Migrate the entire auth/global context system before migrating any pages that depend on it
Option 3 is cleanest. Migrate auth as a dedicated sprint. Until that sprint, leave auth-dependent pages in pages/.
_document.tsx and app/layout.tsx are independent HTML shells. Your _document.tsx defines the HTML structure for all pages/ routes. Your app/layout.tsx defines the HTML structure for all app/ routes. They do not share anything. Fonts, meta tags, third-party scripts injected in _document.tsx are not present on app/ pages. You need to add them to app/layout.tsx separately.
tsx// pages/_document.tsx — this affects ONLY pages/ routes import { Html, Head, Body, Main, NextScript } from 'next/document'; export default function Document() { return ( <Html lang="en"> <Head> <link rel="preconnect" href="https://fonts.googleapis.com" /> {/* ← This font link does NOT apply to app/ routes */} </Head> <Body> <Main /> <NextScript /> </Body> </Html> ); }
tsx// app/layout.tsx — this affects ONLY app/ routes // You need to handle fonts here too — separately export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); }
_app.tsx global CSS imports do not apply to app/ routes. If you import globals.css in _app.tsx, that CSS applies only to pages/ routes. The moment you create app/, routes in that directory will not have your base styles. This manifests as a jarring visual inconsistency between migrated and unmigrated routes — same domain, completely different styling. Fix: import your global CSS file in app/layout.tsx as well.
tsx// app/layout.tsx import '@/styles/globals.css'; // ← same file as _app.tsx imports — duplicate the import import '@/styles/variables.css'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); }
Data fetching timing differs between routers. getServerSideProps runs before the page component renders and passes data as props. App Router RSCs fetch inline — the component itself is async and awaits data directly. During coexistence, users navigating between a pages/ route and an app/ route will experience different loading behaviors on the same domain. This is usually fine, but be aware that any performance comparisons between old and new pages during migration are comparing different execution models.
Migration Map — API by API
Here is the complete translation table. Every Pages Router API has an App Router equivalent. Some are direct replacements. A few require rethinking the pattern entirely.
Data Fetching
The mental model shift: instead of exporting special lifecycle functions that Next.js calls before rendering, your component is async and fetches its own data. No prop threading. No intermediate props type to maintain.
getServerSideProps → async Server Component + fetch() with cache: 'no-store'
getStaticProps → async Server Component + fetch() with default caching (or revalidate export)
getStaticPaths → generateStaticParams
getInitialProps → Do NOT use in App Router (runs on both server and client, defeats the purpose)
getServerSideProps → async RSC:
typescript// BEFORE: pages/product/[id].tsx export async function getServerSideProps({ params }: GetServerSidePropsContext) { const product = await fetchProduct(params.id as string); if (!product) return { notFound: true }; return { props: { product } }; } export default function ProductPage({ product }: { product: Product }) { return <ProductDetail product={product} />; }
typescript// AFTER: app/product/[id]/page.tsx import { notFound } from 'next/navigation'; export default async function ProductPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const product = await fetchProduct(id); if (!product) notFound(); return <ProductDetail product={product} />; }
No props. No getServerSideProps. The component fetches its own data. notFound() from next/navigation replaces the { notFound: true } return. The fetch runs on the server — no client-server round-trip, no prop serialization overhead.
getStaticProps with ISR → RSC with revalidate:
typescript// BEFORE: pages/blog/index.tsx export async function getStaticProps() { const posts = await fetchPosts(); return { props: { posts }, revalidate: 3600, }; } export default function BlogPage({ posts }: { posts: Post[] }) { return <PostList posts={posts} />; }
typescript// AFTER: app/blog/page.tsx export const revalidate = 3600; // route-level ISR — revalidate every hour export default async function BlogPage() { const posts = await fetchPosts(); // cached by the Data Cache automatically return <PostList posts={posts} />; }
The revalidate export at the route level sets ISR behavior for the entire page. No wrapper function needed. fetchPosts can use fetch() with default caching, or unstable_cache for database queries (covered in P-1).
getStaticPaths → generateStaticParams:
typescript// BEFORE: pages/blog/[slug].tsx export async function getStaticPaths() { const posts = await fetchAllPosts(); return { paths: posts.map((post) => ({ params: { slug: post.slug } })), fallback: 'blocking', }; }
typescript// AFTER: app/blog/[slug]/page.tsx export async function generateStaticParams() { const posts = await fetchAllPosts(); return posts.map((post) => ({ slug: post.slug })); } // fallback behavior is controlled by dynamicParams export const dynamicParams = true; // true = 'blocking' equivalent, false = 404 for unknown params
generateStaticParams returns an array of param objects directly — no wrapping in { params: {} }. The fallback option becomes the dynamicParams export: true means unknown paths are rendered on demand (equivalent to fallback: 'blocking'), false means unknown paths return 404 (equivalent to fallback: false).
getInitialProps — do not port this:
getInitialProps runs on both the server (first load) and the client (client-side navigation). It was the original Next.js data fetching mechanism before getServerSideProps and getStaticProps existed. If your codebase uses it (common in older projects, often added by third-party libraries like next-i18next), do not replicate it in App Router. Replace it with proper RSC data fetching — the component fetches its data server-side, full stop.
Routing
The navigation API moved to a new package. This is the single most common cause of runtime errors during migration.
next/router (useRouter) → next/navigation (useRouter)
router.push() → router.push() — same method name, different import
router.query → useSearchParams() for ?param=value, useParams() for [dynamic] segments
router.pathname → usePathname()
router.events → No direct equivalent
The import error you will see:
Error: invariant expected app router to be mounted
or in some versions:
Cannot read properties of null (reading 'push')
Both mean you imported from next/router inside the app/ directory. The next/router package works only in the Pages Router. The next/navigation package works only in the App Router.
typescript// ❌ WRONG in app/ directory import { useRouter } from 'next/router'; // ✅ CORRECT in app/ directory import { useRouter } from 'next/navigation';
Run a search across your entire app/ directory before every PR merge:
bashgrep -r "from 'next/router'" src/app/ # should return zero results
router.query → useSearchParams() + useParams():
In the Pages Router, router.query served double duty — it contained both dynamic path segments (/product/[id] → { id: '123' }) and query string parameters (?tab=reviews → { tab: 'reviews' }). In the App Router, these are separated:
typescript// BEFORE: pages/product/[id].tsx import { useRouter } from 'next/router'; function ProductTabs() { const router = useRouter(); const productId = router.query.id; // dynamic segment const activeTab = router.query.tab; // query param }
typescript// AFTER: app/product/[id]/tabs.tsx — must be 'use client' for hooks 'use client'; import { useParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation'; function ProductTabs() { const params = useParams<{ id: string }>(); const searchParams = useSearchParams(); const productId = params.id; // dynamic segment const activeTab = searchParams.get('tab'); // query param — returns null if absent }
Note that useSearchParams() requires a Suspense boundary wrapping any component that uses it during static rendering. Failing to add this boundary causes a build error: useSearchParams() should be wrapped in a suspense boundary at page "/...". Wrap the component:
tsx// app/product/[id]/page.tsx import { Suspense } from 'react'; import ProductTabs from './tabs'; export default function ProductPage() { return ( <Suspense fallback={<TabsSkeleton />}> <ProductTabs /> </Suspense> ); }
router.events → there is no direct replacement:
Pages Router router.events allowed subscribing to navigation start/complete events for things like NProgress loading bars and analytics page view tracking. App Router has no equivalent event system.
Replacements:
- For loading indicators: use
useTransitionfrom React or Next.js's built-in page loading behavior withloading.tsxfiles. - For analytics page view tracking: put a
useEffectonusePathname()in a Client Component inside yourapp/layout.tsx. Whenpathnamechanges, you've navigated.
typescript// app/components/Analytics.tsx 'use client'; import { usePathname } from 'next/navigation'; import { useEffect } from 'react'; export function Analytics() { const pathname = usePathname(); useEffect(() => { // fires on every client-side navigation analytics.page(pathname); }, [pathname]); return null; }
Include <Analytics /> inside your RootLayout body. It replaces router.events.on('routeChangeComplete', ...).
Layout and Wrapping
The layout system is where the App Router makes the biggest conceptual leap. The Pages Router had _app.tsx (one global wrapper) and the getLayout pattern for per-page layouts. The App Router has nested layout.tsx files — the file system IS the layout tree.
_app.tsx (global wrapper) → app/layout.tsx (root layout)
_document.tsx (HTML shell) → app/layout.tsx (return <html><body>)
getLayout pattern (per-page) → nested layout.tsx files
Migrating _app.tsx to app/layout.tsx:
The core challenge: _app.tsx can be a Client Component because it wraps everything in React Context providers. But app/layout.tsx should be a Server Component for performance. The solution is a thin Providers wrapper that's a Client Component, leaving the layout itself as a Server Component.
typescript// BEFORE: pages/_app.tsx import type { AppProps } from 'next/app'; import { SessionProvider } from 'next-auth/react'; import { ThemeProvider } from '@/components/ThemeProvider'; import '@/styles/globals.css'; export default function App({ Component, pageProps }: AppProps) { return ( <SessionProvider session={pageProps.session}> <ThemeProvider> <Component {...pageProps} /> </ThemeProvider> </SessionProvider> ); }
typescript// AFTER: app/layout.tsx — Server Component, no 'use client' import '@/styles/globals.css'; import { Providers } from '@/components/Providers'; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <Providers>{children}</Providers> </body> </html> ); }
typescript// app/components/Providers.tsx — Client Component for context 'use client'; import { SessionProvider } from 'next-auth/react'; import { ThemeProvider } from '@/components/ThemeProvider'; export function Providers({ children }: { children: React.ReactNode }) { return ( <SessionProvider> <ThemeProvider>{children}</ThemeProvider> </SessionProvider> ); }
The Providers component is 'use client' because React Context requires it. The RootLayout itself stays a Server Component. The children of Providers (your page Server Components) remain Server Components — 'use client' only marks the boundary at Providers, not everything below it.
The getLayout pattern → nested layouts:
Pages Router applications commonly use the getLayout pattern to add per-page layouts without losing state:
typescript// BEFORE: pages/dashboard/settings.tsx DashboardSettingsPage.getLayout = function getLayout(page: React.ReactElement) { return <DashboardLayout>{page}</DashboardLayout>; };
In App Router, you delete this pattern entirely. Create an app/dashboard/layout.tsx file. It wraps all routes under /dashboard automatically:
typescript// AFTER: app/dashboard/layout.tsx import { DashboardSidebar } from '@/components/DashboardSidebar'; import { DashboardHeader } from '@/components/DashboardHeader'; export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return ( <div className="dashboard-shell"> <DashboardHeader /> <DashboardSidebar /> <main>{children}</main> </div> ); }
Every page under app/dashboard/ automatically gets this layout. The getLayout pattern, all the TypeScript gymnastics to make it type-safe, and the _app.tsx layout invocation logic — delete all of it.
API Routes
API routes have the most mechanical migration. The pattern is consistent and the changes are predictable.
pages/api/users.ts → app/api/users/route.ts
req/res (Node HTTP) → NextRequest/NextResponse
export default handler → export async function GET/POST/PUT/DELETE
typescript// BEFORE: pages/api/users.ts import type { NextApiRequest, NextApiResponse } from 'next'; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method === 'GET') { const users = await getUsers(); return res.status(200).json(users); } if (req.method === 'POST') { const body = req.body; const user = await createUser(body); return res.status(201).json(user); } res.setHeader('Allow', ['GET', 'POST']); res.status(405).end(`Method ${req.method} Not Allowed`); }
typescript// AFTER: app/api/users/route.ts import { NextRequest } from 'next/server'; export async function GET() { const users = await getUsers(); return Response.json(users); } export async function POST(request: NextRequest) { const body = await request.json(); const user = await createUser(body); return Response.json(user, { status: 201 }); } // 405 is automatic — if you don't export a method, Next.js returns 405
No if (req.method === 'GET') branching. Each HTTP method gets its own exported function. Next.js automatically returns 405 for methods you don't export. Response.json() is the Web standard — no res.json(). Access request body with await request.json(), not req.body (which was parsed by Next.js middleware in the Pages Router — no equivalent auto-parsing in Route Handlers).
Dynamic API routes:
typescript// BEFORE: pages/api/users/[id].ts export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { id } = req.query; // ... }
typescript// AFTER: app/api/users/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const user = await getUser(id); return Response.json(user); }
Dynamic params are the second argument to Route Handler exports, not on the request object. In Next.js 15, params is a Promise — await it.
Authentication
Authentication is the migration that deserves its own sprint, but here is the translation table:
next-auth v4 (pages/) → Auth.js v5 / next-auth v5 (app/)
getServerSession(req, res, options) → auth() from auth.ts
withAuth middleware → auth() in middleware.ts
useSession() → useSession() (still works with SessionProvider)
The hard truth about next-auth v4: it does not have proper App Router support. getServerSession(req, res, options) requires req and res objects from the Pages Router context. Using it in App Router requires mocking those objects — this is fragile and breaks on minor version updates. Do not go down this path.
Your migration options:
Option 1: Upgrade to Auth.js v5 before migrating authenticated pages. Auth.js v5 is built for the App Router. It exports an auth() function that works in Server Components, Route Handlers, and Middleware without any req/res objects:
typescript// auth.ts (project root) import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [GitHub], });
typescript// app/dashboard/page.tsx import { auth } from '@/auth'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { const session = await auth(); if (!session) redirect('/login'); return <Dashboard user={session.user} />; }
typescript// middleware.ts import { auth } from '@/auth'; export default auth((req) => { if (!req.auth && req.nextUrl.pathname.startsWith('/dashboard')) { return Response.redirect(new URL('/login', req.url)); } });
Option 2: Keep all auth-dependent pages in pages/ until you're ready to upgrade Auth.js. Migrate only public pages to app/. This keeps the migration moving while deferring the auth complexity.
Never attempt to use getServerSession with a fake req/res in App Router. The pattern is documented in some older blog posts and GitHub issues — ignore all of it.
The Ten Pitfalls That Block Every Team Mid-Migration
These are the exact issues that cause teams to lose a day or more. Listed with the symptom, the root cause, and the fix.
1. useRouter from the wrong package
Symptom: Cannot read properties of null (reading 'push') or invariant expected app router to be mounted at runtime. The page fails to load entirely.
Root cause: import { useRouter } from 'next/router' inside a file in the app/ directory. next/router is the Pages Router implementation. It does not exist in the App Router context.
Fix:
bash# Search for the offending import grep -r "from 'next/router'" src/app/ # Replace all instances find src/app -type f -name "*.tsx" -o -name "*.ts" | \ xargs sed -i "s/from 'next\/router'/from 'next\/navigation'/g"
After replacing, audit the usage — router.query needs to be split into useParams() and useSearchParams(). The import replacement is not sufficient on its own if router.query was used.
2. cookies() / headers() in root layout making the entire app dynamic
Symptom: Build output shows λ (server-rendered) for every page. CDN cache hit rate drops to near zero. The next build output has no ○ (static) pages.
Root cause: You added an auth check to app/layout.tsx using cookies() or headers() — a natural instinct when migrating from _app.tsx where a session check was common. Calling cookies() in the root layout opts every single route into dynamic rendering because the root layout wraps every page.
Fix: Move auth checks to individual page files or route-group layouts. For the root layout, do not call cookies() or headers(). If you need to show user-specific content (e.g., the nav bar changes when logged in), put the user-specific part in a Client Component that fetches on the client side, or use a nested layout that only wraps authenticated routes.
3. Global CSS not loading on app/ routes
Symptom: Pages migrated to app/ look completely unstyled — no base fonts, no resets, no utility classes. Pages still in pages/ look correct.
Root cause: Global CSS is imported in pages/_app.tsx. That import only applies to the Pages Router tree.
Fix: Add the same global CSS import to app/layout.tsx:
typescript// app/layout.tsx import '@/styles/globals.css'; // same file that _app.tsx imports import '@/styles/tailwind.css'; // if you use Tailwind separately
4. React Context state lost between pages
Symptom: State set in a Pages Router page (e.g., cart items added on a pages/ product page) disappears when navigating to an app/ page. Or vice versa.
Root cause: Pages Router and App Router have separate React trees. A Context.Provider mounted in one tree has no effect on the other tree.
Fix during coexistence: Move shared state to a medium that both trees can access independently: URL search params, cookies (read via document.cookie client-side or cookies() server-side), or localStorage. The cleanest long-term fix is migrating the entire state system to App Router conventions — but during migration, URL and cookie state are the pragmatic bridge.
5. Middleware running twice causing double redirects
Symptom: Users get redirect loops on certain routes. Middleware logs show two executions for a single request. In some cases, the redirect chain: /login → /dashboard → /login (infinite loop).
Root cause: In certain Next.js 13/14 versions during coexistence, Middleware matched and ran for both the initial request and the subsequent rewrite, effectively executing twice. This is most visible with auth redirects.
Fix: Scope your Middleware matcher precisely. Exclude static files, Next.js internals, and routes that don't need Middleware processing:
typescript// middleware.ts export const config = { matcher: [ '/((?!api|_next/static|_next/image|favicon.ico|public).*)', ], };
For auth specifically, check whether you're handling both the redirect and the destination inside the same Middleware logic and creating a loop. Add logging to Middleware in development — console.log('Middleware hit:', req.nextUrl.pathname) — to see the execution count.
6. getServerSideProps silently ignored in app/
Symptom: A page in app/ renders with no data. No errors. The component just has undefined for what should be server-fetched data.
Root cause: Someone added getServerSideProps to a file inside app/. The Pages Router lifecycle functions are not recognized in app/ — they are exported but never called. The component receives no props. No error is thrown.
Fix: Scan your app/ directory for any exports of Pages Router lifecycle functions:
bashgrep -r "getServerSideProps\|getStaticProps\|getInitialProps\|getStaticPaths" src/app/
Any matches in app/ should be converted to RSC data fetching patterns. This often happens when developers copy-paste from pages/ files during migration and forget to remove the old lifecycle function.
7. next/image domain config conflicts
Symptom: Images work in development, fail in production with Error: Invalid src prop (...) hostname "..." is not configured under images in your next.config.
Root cause: During migration, you may add new remotePatterns for App Router image sources while old pages/ code uses patterns added long ago. The next.config.ts applies to both routers — but inconsistent or environment-specific config can surface differently in production.
Fix: Audit your images.remotePatterns configuration and consolidate. Do not use environment variables inside remotePatterns patterns unless you validate them exist at build time. A missing pattern in production but present in development will silently work locally and fail in production.
typescript// next.config.ts — define all patterns explicitly const config: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.example.com' }, { protocol: 'https', hostname: 'cdn.example.com' }, // add all patterns here — don't rely on conditional logic ], }, };
8. revalidatePath scope mismatch
Symptom: You call revalidatePath('/blog') in a Server Action after publishing a post. The blog page in app/ updates correctly, but users on the old pages/ blog page (not yet migrated) still see stale content.
Root cause: revalidatePath invalidates the App Router Data Cache. It does not invalidate Pages Router ISR cache for the same path. During coexistence — especially if you're running both app/blog and pages/blog at different stages of migration — invalidation must target both caches.
Fix: During the coexistence period, call both:
typescript// In a Server Action or Route Handler import { revalidatePath } from 'next/cache'; // For App Router cache revalidatePath('/blog'); // For Pages Router ISR cache — call the res.revalidate() API // This is typically done via a dedicated API route in pages/api/ await fetch('/api/revalidate?path=/blog&secret=' + process.env.REVALIDATE_SECRET);
typescript// pages/api/revalidate.ts — dedicated revalidation endpoint for Pages Router ISR export default async function handler(req, res) { if (req.query.secret !== process.env.REVALIDATE_SECRET) { return res.status(401).json({ message: 'Invalid token' }); } await res.revalidate(req.query.path as string); return res.json({ revalidated: true }); }
Once the route is fully migrated to app/, remove the dual-invalidation logic.
9. next/font not applying to pages/ routes
Symptom: Typography looks correct on migrated app/ routes but uses fallback fonts on pages/ routes. Console shows no errors.
Root cause: next/font integrates with App Router natively via app/layout.tsx. For pages/ routes, the font variable must still be applied through pages/_document.tsx using the legacy approach. During migration, you have two different font loading systems running simultaneously.
Fix: For the coexistence period, maintain both:
typescript// app/layout.tsx — App Router font loading import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={inter.variable}> <body>{children}</body> </html> ); }
typescript// pages/_document.tsx — Pages Router font loading (legacy, maintain during coexistence) import { Inter } from 'next/font/google'; import { Html, Head, Body, Main, NextScript } from 'next/document'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export default function Document() { return ( <Html className={inter.variable}> <Head /> <Body> <Main /> <NextScript /> </Body> </Html> ); }
Both load the same font. Once all pages are migrated to app/, delete _document.tsx and the font config there.
10. Type errors with async params — Next.js 15 breaking change
Symptom: TypeScript error: Type '{ id: string }' is not assignable to type 'Promise<{ id: string }>'. Existing App Router code written for Next.js 13/14 breaks after upgrading to 15.
Root cause: Next.js 15 changed the type of params (and searchParams) in page components from a plain object to a Promise. Code written against the Next.js 13/14 App Router API does not have this Promise wrapping.
typescript// Next.js 13/14 — params is a plain object export default async function Page({ params }: { params: { id: string } }) { const product = await fetchProduct(params.id); // direct access } // Next.js 15 — params is a Promise export default async function Page({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; // must await const product = await fetchProduct(id); }
Fix: Run the Next.js codemod:
bashnpx @next/codemod@latest async-params .
The codemod automatically adds await params and updates TypeScript types across your codebase. Run it after upgrading to Next.js 15, before doing any other migration work.
Migration Checklist
Use this in order. Each item unblocks the next.
□ Audit pages/ directory — list all routes, categorize by:
□ Leaf routes (no shared layout, no auth dependency)
□ API routes
□ Static content pages (getStaticProps)
□ Dynamic pages (getServerSideProps)
□ Auth-dependent pages
□ Pages using global context
□ Set up the app/ directory foundation
□ Create app/layout.tsx with html/body structure
□ Import all global CSS files in app/layout.tsx
□ Create app/components/Providers.tsx ('use client') with context wrappers
□ Include <Providers> in app/layout.tsx
□ Set up font loading
□ Configure next/font in app/layout.tsx
□ Maintain matching font config in pages/_document.tsx for coexistence period
□ Set up metadata
□ Add base metadata export to app/layout.tsx
□ Verify <head> meta tags from _document.tsx are replicated
□ Migrate leaf routes first (/about, /terms, /privacy, /404)
□ Create app/[route]/page.tsx for each
□ Verify no 'next/router' imports in new files
□ Verify styles apply correctly
□ Test navigation to and from the migrated page
□ Migrate static content pages (getStaticProps → async RSC)
□ Move data fetching inline, add revalidate export if needed
□ Replace getStaticPaths with generateStaticParams
□ Set dynamicParams export appropriately
□ Migrate API routes (pages/api/ → app/api/ route handlers)
□ Convert handler(req, res) → exported GET/POST functions
□ Replace NextApiRequest/Response → NextRequest/Response
□ Update client-side fetch() call paths if needed
□ Migrate pages with getServerSideProps → async RSCs
□ Remove getServerSideProps export
□ Fetch data inline in async Server Component
□ Replace { notFound: true } with notFound() from next/navigation
□ Replace { redirect: ... } with redirect() from next/navigation
□ Plan and execute auth migration (dedicate a sprint)
□ Upgrade next-auth v4 → Auth.js v5
□ Replace getServerSession → auth() from auth.ts
□ Replace withAuth middleware → auth() in middleware.ts
□ Update SessionProvider to live in app/components/Providers.tsx
□ Migrate authenticated route tree
□ Create app/dashboard/layout.tsx (or equivalent) with auth check
□ Migrate each dashboard/settings page one at a time
□ Final cleanup — only after all routes migrated
□ Delete pages/_app.tsx
□ Delete pages/_document.tsx
□ Delete pages/ directory
□ Remove pages/ from tsconfig.json include if present
□ Remove any dual-invalidation logic (revalidatePath + res.revalidate)
□ Remove duplicate font loading
□ Verification
□ grep -r "from 'next/router'" src/app/ → zero results
□ grep -r "getServerSideProps\|getStaticProps\|getInitialProps" src/app/ → zero results
□ npm run build — check render mode symbols (○ ƒ ◐)
□ Review ○ vs ƒ distribution — unexpected ƒ pages have a dynamic rendering trigger
□ Run E2E tests across all migrated routes
What to Do About next-auth v4 During Migration
Most production Next.js applications built before 2024 use next-auth v4. This is the most common blocker for migrating auth-dependent routes to App Router. Here is the practical guidance.
Option A: Complete the Auth.js v5 upgrade simultaneously with migration
Auth.js v5 (the rebranded next-auth v5) was rebuilt specifically for the App Router. The auth() function works everywhere — Server Components, Route Handlers, and Middleware — without needing request and response objects.
The upgrade has breaking changes beyond the import path:
- Session shape changes (
session.user.idis now directly available with proper TypeScript typing) - Configuration structure changes (
authOptionsexport is replaced withNextAuth()configuration) - Database adapter API changes for some adapters
- JWT encryption key changes (requires
AUTH_SECRETto be updated)
Budget a full sprint for the Auth.js v5 migration independent of your route migrations. Do not try to do both simultaneously for the first time.
Option B: Defer auth-dependent routes entirely
Keep all pages that check session state in pages/. Migrate only public routes to app/. This is the pragmatic choice if you cannot immediately budget an auth upgrade sprint. It allows the migration to proceed for 70–80% of your routes while the auth migration is planned separately.
The failure mode to avoid: attempting to use getServerSession(req, res, authOptions) from next-auth v4 inside App Router code by constructing fake req/res objects. This pattern appears in migration guides and Stack Overflow answers. It works — sometimes — on next-auth v4 patch versions and breaks silently on others. It is not supported behavior. Do not build production code on it.
typescript// DO NOT DO THIS — fragile and unsupported import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { cookies, headers } from 'next/headers'; // Constructing fake req/res for getServerSession in App Router // This is a hack that breaks on next-auth updates const req = { headers: Object.fromEntries(await headers()), cookies: ... }; const session = await getServerSession(req, {} as any, authOptions);
The correct solution is Auth.js v5. Coordinate the upgrade. Do not shortcut it.
The End State — Removing pages/ Entirely
You will know the migration is complete when your pages/ directory contains nothing but files you intend to delete. Before you run rm -rf pages/, verify:
All routes are migrated. The safest check is to look at your Next.js build output (npm run build). Any route still in pages/ appears in the build output. Cross-reference that list against your route inventory from the audit step. There should be zero entries.
No stray lifecycle function exports in app/. Run these searches and expect zero results:
bashgrep -r "export async function getServerSideProps" src/app/ grep -r "export async function getStaticProps" src/app/ grep -r "export async function getInitialProps" src/app/ grep -r "export async function getStaticPaths" src/app/
These export silently and produce pages that render without data. They are easy to miss if a developer copy-pasted from a pages/ file during migration.
No pages/api/ routes remaining. Check:
bashls pages/api/ 2>/dev/null && echo "API routes remain" || echo "Clean"
_app.tsx and _document.tsx confirmed deleted. If they exist but are empty of meaningful configuration, they are harmless but should be cleaned up. If _app.tsx still has providers or global styles that were not migrated, deleting it will break the pages/ routes you thought were fully migrated.
pages/ removed from tsconfig.json if explicitly included:
json// tsconfig.json — remove pages/ from include if present { "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts" // "pages/**/*" — remove this line ] }
Final build verification. After deleting pages/, run npm run build and review the output:
Route (app) Size First Load JS
○ / 5.2 kB 94.1 kB
○ /about 1.1 kB 89.2 kB
ƒ /blog/[slug] 2.4 kB 91.6 kB
○ /blog 3.8 kB 93.0 kB
ƒ /dashboard 4.1 kB 93.8 kB
ƒ /api/users 0 B 0 B
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
◐ (Partial) prerendered as static HTML with dynamic data (PPR)
The ƒ pages should be intentional — dashboard routes that check session, API routes, pages with export const dynamic = 'force-dynamic'. If a page you expected to be ○ shows as ƒ, trace the dynamic rendering trigger. It is almost always cookies() or headers() called somewhere in that route's render chain.
Once the build output matches your expectations and your E2E test suite passes, the migration is complete. The pages/ directory is gone. You are running purely on the App Router.
Where We Go From Here
Migration is behind you. Every route is now an async Server Component or a Client Component with a clear boundary. The architecture is consistent across the codebase. The next module — A-1 — goes underneath the App Router to explain what is actually happening when the server renders a React Server Component and sends it to the client. Understanding the React Server Component protocol, the wire format, and how hydration works at the byte level is what separates engineers who debug App Router issues from first principles versus engineers who cargo-cult until something works. P-16 got you migrated. A-1 explains what you are now running.