Next.js Pages Router to App Router Migration Practical Guide: Progressive Strategy & Pitfall Checklist
Friday afternoon at 3 PM, the tech director threw out a question in the meeting room: “Can we upgrade this Next.js 12 project to 14?”
I stared at that old project running for two years on the screen, my heart tightened. Honestly, my first reaction was—absolutely not. Not that I don’t want to upgrade, but last time we upgraded React 17, we spent a whole week fixing bugs, customer service phones were ringing off the hook.
But this time seemed a bit different.
That night I started flipping through official docs, saw those App Router new features: Server Components, nested layouts, better performance… my heart itched again. But when I flipped to the migration guide page, I got worried again—screen full of API comparison tables, what to change getServerSideProps to, what to split _app.js into… headache.
What’s more troublesome, the official recommended “progressive migration” sounds great, but when actually tried, discovered: when switching pages between /pages and /app, users see loading spinner, experience actually got worse.
I spent two full weeks stepping into pitfalls, flipping through community discussions, trying different approaches, finally summarized a relatively reliable migration strategy. This article will share these practical experiences, including:
- How to judge if your project is worth migrating
- Real pros and cons of two migration strategies (not theory from official docs)
- Detailed steps and code examples for getServerSideProps migration
- 7 major pitfalls I personally stepped into and solutions
If you’re also struggling with whether to upgrade, or have started migration but encountered problems, hope this article helps you avoid some detours.
Why Migrate? Calculate the Cost First
Speaking of migration, I need to pour cold water first: not all projects are worth the trouble.
Last month a friend asked me if their company’s event page that’s about to be taken offline should upgrade to App Router. I directly talked them out of it—code that’s going to be deleted in six months, why waste time?
So what kind of projects are worth migrating? I’ve summarized a few judgment criteria:
Nested Layout Needs
This was our team’s main reason for migrating. Our SaaS admin has a sidebar + top nav + content area three-layer layout. With Pages Router, every time you switch pages, the sidebar re-renders completely.
Users click to a new page, can clearly feel the whole interface flash. Not slow network, just layout repaint.
App Router’s nested layouts perfectly solved this. After migration, WorkOS team reported “login experience significantly improved, no loading states or layout jitter” — we tested ourselves, same result. When users switch pages, only content area updates, nav bar rock solid.
Performance Optimization Space
If your project’s first screen load time exceeds 3 seconds, App Router might help.
We had a product list page, previously used getServerSideProps to pull data, every refresh had to wait for server to render entire HTML. After changing to Server Components, can pull list data on server, directly stream to client, first screen time dropped from 3.2 seconds to 1.8 seconds.
But there’s a pitfall here: not all pages can speed up. Pure client-interactive pages (like canvas editors), after migration basically no difference, might even slow down due to extra abstraction layer.
Long-Term Maintenance Projects
If this project needs maintenance for three years or more, migrate early, enjoy early. Vercel has clearly stated, future new features will prioritize App Router support, Pages Router entered “maintenance mode.”
I don’t want to migrate again in two years, by then APIs might have changed again, pitfalls will only be more.
Scenarios Not Recommended for Migration
But in these situations, I suggest don’t rush to migrate:
- Project about to be taken offline — Not necessary
- Small static sites (5 pages or less) — Benefits too small, not worth it
- Team not familiar with React 18 — Understand Suspense and Server Components first
- Heavily dependent on old third-party libraries — You might discover a bunch of incompatible libraries
After all this, core message is one sentence: don’t migrate just to migrate. Ask yourself first, what actual problems does migration solve? If the answer is “none, just want to try new stuff,” then forget it.
Our team calculated: invest two weeks of manpower, get user experience improvement and three years of technical debt reduction, worth it. What about your project?
Choosing Between Two Migration Strategies
Official docs recommend “progressive migration,” sounds safe—take it slow, one page at a time.
But after trying, I discovered this thing has a fatal problem.
Progressive Migration Pitfalls
Imagine this scenario: you migrated homepage to /app directory, but product detail page is still in /pages. User clicks from homepage to product page, page suddenly white screen spinner… spins then shows content.
Why? When pages jump from App Router to Pages Router, Next.js treats them as two separate applications, entire JavaScript bundle needs to reload. User experience instantly back to 2010.
WorkOS team also complained about this in their blog: “Navigating between different routers is like jumping between two unrelated applications.” They originally wanted progressive migration too, later gave up.
So is progressive migration completely unfeasible? Not really.
Scenarios Suitable for Progressive:
- Pages have low coupling (like blogs, articles aren’t related)
- Can migrate by complete modules (like migrate entire user center module first, then product module)
- Can accept loading state when switching
I’ve seen a tech blog do this, worked okay. But SaaS products or e-commerce sites, forget it.
WorkOS Zero-Downtime Approach
So what about complex projects? WorkOS gave a clever solution.
Their approach: create a temporary directory /app/new under /app, rewrite all pages here, then use query parameters to control which version to access.
Sounds convoluted, code makes it clear:
// next.config.js
module.exports = {
async rewrites() {
return [
{
source: '/:path*',
destination: '/new/:path*',
has: [
{
type: 'query',
key: 'new',
value: 'true',
},
],
},
]
},
}
This way, regular users accessing /dashboard still use old version, but adding ?new=true shows new version.
Testers, product, designers can verify new version in production environment early, users completely unaware. After new version tested and confirmed fine, move /app/new to /app, delete /pages, done.
Our team used this approach. Throughout migration, users encountered zero bugs, because before official launch we tested with real data for a week.
Specific Steps:
- Upgrade Next.js to 14 — Don’t touch /pages yet, just upgrade framework version
- Migrate Route Hooks — Change
next/routertonext/navigation, ensure code compatible with both routers - Create /app/new Directory — Rebuild page structure here
- Reuse Existing Components — Directly import React components from /pages, no need to rewrite
- Configure Rewrites — Add above config, use
?new=trueto switch - Internal Testing + Gradual Rollout — Let team use new version, fix issues promptly
- Official Launch — Move /app/new to /app, delete rewrites and /pages
From step 1 to step 7, we spent 10 working days total. 6 days rewriting pages, 3 days fixing bugs, last day launch.
My Recommendation
If your project:
- Less than 10 pages, pages independent → Progressive Migration
- More than 10 pages, high user experience requirements → Zero-Downtime Approach
- New project → Use App Router directly, don’t bother
Don’t think about developing new features while migrating, I tried, ended up with completely different code styles on both sides, painful to look at. Either concentrate two weeks to finish, or don’t touch it yet.
getServerSideProps Migration Practice
This is the question most people ask me: “getServerSideProps can’t be used, how to get data?”
Actually App Router data fetching is simpler, just need to change thinking.
From “Separation” to “Merging”
Pages Router logic is like this: data fetching (getServerSideProps) and UI (component) written separately, Next.js helps you call data function on server, pass results to component.
App Router doesn’t play this game. Page component itself is an async function, directly pull data inside:
// ❌ Old approach: pages/project/[id].tsx
export async function getServerSideProps(context) {
const { id } = context.params
const res = await fetch(`https://api.example.com/projects/${id}`)
const project = await res.json()
return {
props: { project }
}
}
export default function ProjectPage({ project }) {
return <h1>{project.title}</h1>
}
// ✅ New approach: app/project/[id]/page.tsx
export default async function ProjectPage({ params }) {
const { id } = params
const res = await fetch(`https://api.example.com/projects/${id}`, {
cache: 'no-store' // Key! Equivalent to getServerSideProps behavior
})
const project = await res.json()
return <h1>{project.title}</h1>
}
Looks much simpler, right? But wait, there are two major pitfalls here.
Pitfall 1: Cache Config Wrong
By default, App Router’s fetch is cached (equivalent to getStaticProps), doesn’t pull new data on every request.
When I first migrated, didn’t notice this, migrated product price page, result prices never updated. Users complained “clearly price dropped, page still shows original price,” I debugged for a while before discovering it was cache’s fault.
Remember this comparison table:
getServerSideProps→cache: 'no-store'getStaticProps→cache: 'force-cache'(default behavior)getStaticProps + revalidate→next: { revalidate: 60 }
Pitfall 2: What About Client State?
Pages that originally used getServerSideProps often also have client interactions, like filtering, sorting.
After migrating to App Router, you’ll discover: async Server Component can’t use useState, useEffect these hooks.
What to do? Split components.
// app/products/page.tsx (Server Component)
export default async function ProductsPage() {
const products = await fetchProducts() // Pull data on server
return <ProductList initialData={products} /> // Pass to Client Component
}
// components/ProductList.tsx (Client Component)
'use client' // Note this line!
import { useState } from 'react'
export function ProductList({ initialData }) {
const [products, setProducts] = useState(initialData)
const [filter, setFilter] = useState('')
// Client-side filtering logic
const filtered = products.filter(p => p.name.includes(filter))
return (
<div>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</div>
)
}
This way, server handles data pulling, client handles interaction, clear responsibilities.
But note: Don’t abuse “use client”. I’ve seen people mark entire page as “use client”, then loses the point of Server Components.
Actual Migration Steps
I summarized a two-step process:
Step 1: Split Components
First in original pages directory, split components into “pure display” and “stateful” parts, test passes.
Step 2: Move to app Directory
- Put pure display part in app/[route]/page.tsx, mark as async, pull data inside
- Extract stateful part to separate file, add “use client”
- Delete getServerSideProps code
Benefit of this approach: if something goes wrong, can quickly rollback, won’t mess up both sides.
One More Detail
Originally used context.req.cookies to read user identity, now change to:
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const token = cookieStore.get('auth-token')
// Get token to request user data...
}
Similar ones include headers(), redirect(), all imported from next/headers or next/navigation. Official docs have complete list, I won’t copy it.
7 Common Major Pitfalls & Solutions
Alright, main event. These 7 pitfalls below, I’ve stepped into all of them, each took me at least an hour to debug.
Pitfall 1: Server Errors Swallowed
Symptom: Page doesn’t render, no error either, just shows loading skeleton or blank.
My Experience: Once modified an API call, result page completely blank. Opened console, no errors at all. Thought data didn’t return, added a bunch of console.log, still didn’t find problem.
Finally discovered, server threw an exception, but because I didn’t configure error.tsx, Next.js swallowed the error, directly showed Suspense fallback.
Solution: Add error.tsx in each route directory:
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Error: {error.message}</h2>
<button onClick={reset}>Retry</button>
</div>
)
}
With this, at least can see error message. Development environment Next.js shows detailed stack, production shows friendly error message.
Pitfall 2: useRouter Doesn’t Work
Symptom: useRouter().push() doesn’t navigate, or errors saying some method doesn’t exist.
Reason: next/router and next/navigation are two different APIs, incompatible.
I initially thought just change import path:
// ❌ Wrong approach
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // push method doesn't exist!
Later learned, next/navigation’s useRouter doesn’t have push method, need to use separate function:
// ✅ Correct approach
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
const router = useRouter()
router.push('/dashboard') // This actually exists, but behavior different
// Or directly use Link component
import Link from 'next/link'
<Link href="/dashboard">Navigate</Link>
Comparison Table (I stuck this next to my computer, kept looking during migration):
| Pages Router | App Router |
|---|---|
useRouter().push(url) | useRouter().push(url) (exists, but not recommended) |
useRouter().pathname | usePathname() |
useRouter().query | useSearchParams() |
useRouter().asPath | usePathname() + useSearchParams() |
Pitfall 3: Dynamic Import Fails
Symptom: Components imported with next/dynamic don’t render, console errors “You’re importing a component that needs useState. It only works in a Client Component…”
Reason: Server Component renders on server by default, some client-only libraries (like chart libraries) error.
I had a chart page before, used ECharts:
// ❌ This will error
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export default function Page() {
return <Chart data={data} />
}
Error says Chart component needs window object, but server doesn’t have it.
Solution: Add “use client” to page.tsx, or extract Chart to separate Client Component:
// app/charts/page.tsx
import { ClientChart } from './ClientChart'
export default function Page() {
return <ClientChart />
}
// app/charts/ClientChart.tsx
'use client'
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
export function ClientChart() {
return <Chart data={data} />
}
Pitfall 4: Page Flicker When Switching
Symptom: Click link to navigate, entire page re-renders, top nav, sidebar all flash.
Reason: layout.tsx not configured correctly, or didn’t use layout at all.
App Router’s core advantage is nested layouts, but I didn’t use it well initially, wrote nav directly in each page.tsx, of course it flashes.
Correct Approach:
// app/layout.tsx (Root layout, shared by all pages)
export default function RootLayout({ children }) {
return (
<html>
<body>
<Header /> {/* Top nav, never re-renders */}
{children}
</body>
</html>
)
}
// app/dashboard/layout.tsx (Dashboard layout)
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar /> {/* Sidebar, doesn't re-render when switching pages within dashboard */}
<main>{children}</main>
</div>
)
}
This way, when users switch between /dashboard/analytics and /dashboard/settings, only main area updates, sidebar stays put.
Pitfall 5: 404 Page Doesn’t Work
Symptom: Custom 404 page doesn’t display, still shows Next.js default.
Reason: Pages Router’s 404.js conflicts with App Router’s not-found.tsx.
When I migrated, /pages/404.js was still there, result App Router’s /app/not-found.tsx didn’t work.
Solution: Delete /pages/404.js and /pages/500.js, use App Router convention:
// app/not-found.tsx
export default function NotFound() {
return <h1>Page Not Found</h1>
}
Manually trigger 404 in page.tsx:
import { notFound } from 'next/navigation'
export default async function Page({ params }) {
const data = await fetchData(params.id)
if (!data) {
notFound() // Trigger 404
}
return <div>{data.title}</div>
}
Pitfall 6: Dev Server Gets Slower
Symptom: Fine when first starts, after changing code a few times, hot reload takes 10 seconds, later crashes directly.
Honestly: I didn’t perfectly solve this either.
This is a known Next.js 14 issue. FlightControl team complained in their blog: “dev server performance so bad I’d give up all new features to avoid it.” They said they have to restart dev server every 20 minutes.
Our team’s experience was similar.
Temporary Solutions:
- Regularly restart dev server (I set a 15-minute reminder)
- Use
next dev --turboto enable experimental Turbopack (faster, but occasional bugs) - Reduce unnecessary Server Components, some pages actually Client Component is enough
Next.js 15 supposedly improved this, but I haven’t tried yet.
Pitfall 7: Third-Party Libraries Incompatible
Symptom: Some animation libraries (Framer Motion, Lottie) error, say can’t find window or document.
Reason: These libraries are pure client-side, can’t use in Server Component.
I used Framer Motion for page transition animations before, after migration all broke.
Solutions:
- Wrap components using these libraries with “use client”
- Check if library has updated version supporting React 18 (some already adapted)
- If really can’t, switch to SSR-compatible library
Especially watch out for these common client-only libraries:
- Framer Motion (page exit animations have issues in App Router, still discussing in official issues)
- swiper, slick-carousel and other carousel libraries
- Various chart libraries (ECharts, Chart.js, etc.)
- Drag-and-drop libraries (react-dnd, dnd-kit, etc.)
If your project heavily depends on these libraries, check GitHub issues before migration to confirm compatibility.
Post-Migration Optimization Suggestions
Migration complete isn’t the end, still plenty of optimization space.
Reduce Client JavaScript
This is Server Components’ biggest advantage.
Our original product list page, React components alone were 120KB (gzipped). After migration, changed data display part to Server Component, only filtering and sorting as Client Component, bundle size dropped to 45KB.
Check Method:
npm run build
Look at output, which pages marked (Static) or (SSR), which are ○ (indicates used Client Component). If screen full of ○, you might be using “use client” too much.
Optimization Tips:
- Static content (text, images) in Server Component
- Interactive components (forms, buttons) use Client Component
- Don’t mark entire page as “use client”, only mark needed sub-components
Use Caching Reasonably
App Router’s caching strategy is much more complex than Pages Router.
// No cache, pull new data on every request (suitable for real-time data)
fetch(url, { cache: 'no-store' })
// Cache 60 seconds, revalidate after (suitable for frequently updated but not real-time data)
fetch(url, { next: { revalidate: 60 } })
// Permanent cache (suitable for unchanging static data)
fetch(url, { cache: 'force-cache' })
Our product list used 60-second revalidate, ensures data not too stale, reduces server pressure. After launch, API calls reduced by 60%.
Performance Monitoring
Compare these metrics before and after migration:
- First Contentful Paint (FCP) - Time user sees first content
- Time to Interactive (TTI) - Time page fully interactive
- Cumulative Layout Shift (CLS) - Whether page jitters when loading
We monitored with Vercel Analytics, found after migration FCP dropped from 3.2 seconds to 1.8 seconds, TTI from 5.1 seconds to 3.3 seconds.
But note, not all pages will get faster. Pure client-interactive pages (like our canvas editor), after migration almost no difference.
Be Careful of Over-Optimization
Don’t force component splitting just to use Server Component.
I once split a form into 20 small components, thinking “split finer, higher Server Component ratio.” Result: code readability worse, colleagues couldn’t understand, maintenance cost actually increased.
Rule of Thumb: If a component needs useState/useEffect, directly mark “use client”, don’t overthink. Server Components are tools, not KPIs.
Conclusion
After all this, let’s summarize core points:
Migration isn’t to follow trends—it’s to solve actual problems. If your project needs nested layouts, wants to reduce client JS, or needs long-term maintenance, App Router is worth investing time.
Choose the Right Migration Strategy: Small projects progressive slowly, large projects use zero-downtime approach one-time switch. Don’t think about developing while migrating, will be messy.
Stepping into pitfalls is normal—the 7 pitfalls I listed are just the tip of the iceberg. When encountering problems, first search GitHub issues, 90% of pitfalls others have already stepped into.
Don’t Over-Optimize—Server Components are tools, not goals. Code readability and team efficiency are more important than bundle size.
For migration, my suggestion is: first pick 1-2 pages to pilot, get process working, summarize experience, then push forward comprehensively. Our team did this—first page took 3 days, after understanding the pattern, next 10 pages only took 5 days.
Finally want to say, Next.js App Router indeed has many problems (especially dev server performance), but overall direction is right. As ecosystem matures, these pitfalls will gradually be filled.
If you’re also encountering problems during migration, welcome to discuss in comments, maybe I’ve stepped into the same pitfalls.
Related Resources:
- Next.js Official Migration Guide
- WorkOS Zero-Downtime Migration Approach
- App Router Common Issues (GitHub Discussions)
Wishing you a smooth migration!
FAQ
What's the difference between progressive migration and zero-downtime migration?
• Migrate one page at a time
• Suitable for small projects
• But there's a loading state when switching between /pages and /app
Zero-downtime migration:
• Rebuild all pages in /app/new directory
• Use query params to control version switching
• Test internally before going live
• Suitable for large projects with high user experience requirements
How do I get data after migrating getServerSideProps?
Note the cache options:
• getServerSideProps → cache: 'no-store'
• getStaticProps → cache: 'force-cache'
If the page has client-side interactions, split into Server Component (fetch data) and Client Component (handle interactions).
Why does the page flicker when switching routes?
You should:
• Create app/layout.tsx as root layout
• Create sub-layouts for functional areas (e.g., app/dashboard/layout.tsx)
This way, only the content area updates when switching pages, navigation bars and sidebars don't re-render, avoiding flicker.
How do I use useRouter in App Router?
Changes:
• useRouter().pathname → usePathname()
• useRouter().query → useSearchParams()
Recommendation: Use Link component directly for navigation instead of router.push().
What if third-party libraries are incompatible after migration?
Common incompatible libraries:
• Framer Motion
• Chart libraries (ECharts, Chart.js)
• Carousel libraries
Before migrating, check the library's GitHub issues to confirm compatibility.
How do I fix slow dev server?
Temporary solutions:
• Restart dev server regularly (recommend every 15 minutes)
• Use next dev --turbo to enable Turbopack (faster but occasionally has bugs)
• Reduce unnecessary Server Components
Next.js 15 reportedly improves this issue.
How long does migration take?
• Small projects (<10 pages): 3-5 days
• Large projects: 2-3 weeks
Recommendation: Start with 1-2 pages as a pilot, run through the process, then roll out comprehensively. Our team took 3 days for the first page, then only 5 days for the next 10 pages.
14 min read · Published on: Dec 18, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup

Comments
Sign in with GitHub to leave a comment