By mid-2025, Next.js 15 had made the App Router the clear default for new projects — 80% of new Next.js projects start with it. But what about the hundreds of thousands of existing Pages Router apps? I migrated a production commercial web app at Commsult from Pages Router to App Router, and it was one of the most educational (and occasionally painful) experiences I've had as a developer. This is everything I learned.
Pages Router isn't going away tomorrow — Vercel still maintains it and it still ships new features. But App Router unlocks capabilities that Pages Router fundamentally cannot offer: React Server Components (zero client-side JS for data-fetching components), Streaming with Suspense, parallel routes, intercepting routes, and the new caching model. On our commercial app, the main motivation was performance — Server Components let us move significant data-fetching logic off the client, reducing Time to Interactive (TTI) measurably.
The biggest hurdle isn't syntax — it's thinking in two rendering environments simultaneously. In Pages Router, everything is a React component and data fetching happens in getServerSideProps or getStaticProps at the page level. In App Router, every component defaults to being a Server Component. The moment you add useState, useEffect, or any browser API, you need to mark it 'use client'. This boundary is not just a directive — it's an architectural decision about where state lives.
Pages Router (before) App Router (after)
───────────────────── ──────────────────────
pages/ app/
├── index.tsx ├── [locale]/
├── about.tsx │ ├── page.tsx ← Server Component
├── blog/[slug].tsx │ ├── layout.tsx ← Shared UI
└── api/ │ └── (pages)/
└── hello.ts ├── about/
│ └── page.tsx
getServerSideProps() │ └── blog/
getStaticProps() → │ └── [slug]/page.tsx
└── api/
└── route.ts ← Route Handler
Data fetching: getServerSideProps Data fetching: async Server Component
Client state: useRouter() Client state: useSearchParams()
Dynamic route: useRouter.query Dynamic route: params prop (async)
Images: next/image Images: next/image (same)From my migration experience: start by auditing every component for useState, useEffect, and event handlers before you touch a single file. Color-code them — green for pure render (can stay server), yellow for data hooks only, red for browser interaction. This audit saves you from 'use client' sprawl where you mark entire subtrees as client when only a leaf component needs it.
The official Next.js migration guide is good but undersells the real friction. In practice, the hardest parts were: (1) Libraries that assume browser globals — chart libraries, rich text editors, date pickers that access window or document directly will throw on the server. You need dynamic() with ssr: false for these. (2) useRouter from next/router is completely different from useRouter in next/navigation — same name, different behavior. The App Router's useRouter cannot read query params; you need useSearchParams() separately. (3) API routes move from pages/api/ to app/api/[...route]/route.ts with a completely different export signature.
getServerSideProps and getStaticProps don't exist in App Router. Server Components fetch data directly — you just await fetch() or call Prisma at the top of the component. This is simpler in theory but requires understanding Next.js's automatic caching. By default, fetch() in Server Components is cached. If you need fresh data on every request, you must add { cache: 'no-store' }. If you need revalidation, use { next: { revalidate: 60 } }. Miss this and you'll spend hours debugging why your data is stale.
// BEFORE: Pages Router
export async function getServerSideProps() {
const data = await prisma.post.findMany()
return { props: { posts: data } }
}
export default function Blog({ posts }) { ... }
// AFTER: App Router Server Component
// app/[locale]/(pages)/blog/page.tsx
export default async function Blog() {
const posts = await prisma.post.findMany() // direct DB call — no API route needed
return <PostList posts={posts} />
}
// For fresh data on every request:
const data = await fetch('/api/posts', { cache: 'no-store' })
// For ISR-style revalidation:
const data = await fetch('/api/posts', { next: { revalidate: 60 } })
// useRouter migration:
// BEFORE (Pages Router)
import { useRouter } from 'next/router'
const router = useRouter()
const { slug } = router.query
// AFTER (App Router)
import { useRouter, useSearchParams } from 'next/navigation'
const searchParams = useSearchParams()
const slug = searchParams.get('slug')You don't have to rewrite everything at once. Next.js supports running Pages Router and App Router side-by-side during migration. WorkOS's approach — which I found invaluable — is to test the entire App Router version behind a feature flag before switching production traffic. We started with the least interactive pages (marketing landing pages, blog), converted those to Server Components, validated, then moved to authenticated dashboard pages. The dashboard was hardest because it's all client state: tables with sorting, forms with validation, real-time status updates.
Critical: Never rely on Next.js middleware as your sole authorization gate. CVE-2025-29927 exposed that middleware could be bypassed by manipulating the x-middleware-subrequest header. Your actual authorization logic must live in Server Components or API routes — middleware is for routing decisions and locale detection, not security enforcement. Always verify session/permissions server-side inside the route handler itself.
Today, all new projects at Commsult start with App Router. The benefits compound: Streaming means users see content progressively rather than waiting for the slowest data fetch. Server Components mean the Prisma client never gets shipped to the browser. Nested layouts mean shared state (like a user session) flows down without prop drilling. For brownfield migrations, I do it incrementally — Pages Router stays for complex interactive sections, App Router takes over static and SSR pages first. The key insight: App Router is not harder, it just requires understanding a new mental model. Once it clicks, it's genuinely better.
Before starting any Pages Router to App Router migration: (1) Audit all third-party libraries for SSR compatibility — check their issues for 'use client' or 'window is not defined' reports. (2) Map all getServerSideProps and getStaticProps usages to their App Router equivalents. (3) Identify all pages that use useRouter for query params — they'll need useSearchParams. (4) Plan the layout hierarchy — root layout, locale layouts, dashboard layouts — before writing code. (5) Set up the App Router under /app alongside your existing /pages to run both simultaneously during migration. (6) Never put authorization logic only in middleware — always enforce it in the route handler.