Home JavaScript Pages Router to App Router — Mixed Router Caused API Routes to Return 404 for 3 Days
Advanced 7 min · July 12, 2026

Pages Router to App Router — Mixed Router Caused API Routes to Return 404 for 3 Days

A mixed Pages Router + App Router deployment caused API routes to return 404 for 3 days.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 25 min
  • Node.js 18+
  • Existing Next.js Pages Router project
  • Understanding of both routing systems
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Both routers CAN coexist in one Next.js project — but routes CANNOT overlap. A pages/api/user.js and app/api/user/route.js will cause one to silently 404
  • Incremental migration: start with the App Router for NEW pages, then migrate existing pages one at a time. Do NOT attempt a full rewrite
  • Data fetching migration: getServerSideProps → fetch() with cache: 'no-store' in Server Components; getStaticProps → fetch() with next: { revalidate: N }
  • Layout nesting is the biggest architectural difference: App Router uses nested layout.tsx files per folder; Pages Router uses _app.tsx and _document.tsx globally
  • The migration order matters: migrate leaf pages first (most nested routes), then layouts, then root layout last. This prevents cascading changes
✦ Definition~90s read
What is Pages Router to App Router Migration?

The migration from Pages Router to App Router is the process of moving a Next.js application from the original routing system (pages/ directory, getServerSideProps, getStaticProps, _app.tsx) to the newer routing system (app/ directory, Server Components, async data fetching, nested layouts). Both routers can coexist in the same project, allowing incremental migration.

Imagine renovating a house room by room while still living in it.

The key architectural differences: data fetching moves from decoupled functions (getServerSideProps/getStaticProps) to direct async/await in Server Components. Layouts change from a single global _app.tsx to nested layout.tsx files per directory. Error and loading states change from manual state management to file-convention-based boundaries (error.tsx, loading.tsx, not-found.tsx).

API routes change from a single default export handling all methods (switch on req.method) to named exports per method (GET, POST, PUT, DELETE).

The incremental migration strategy: create the app/ directory alongside pages/, migrate routes one at a time beginning with API routes (migrated in one batch to avoid route conflicts), then leaf pages (most nested first), then layouts (bottom-up). Each migration PR removes the route from pages/ and adds it to app/.

The route conflict check — ensuring no URL path exists in both routers — is the most critical pre-deploy validation.

The migration typically takes 4-8 weeks for a 30-50 page application. The benefits: better performance (Server Components ship zero JS for server-only code), simpler data fetching (async/await instead of decoupled functions), persistent layouts (state preserved across navigations), and convention-based error/loading boundaries (no manual Suspense management).

Plain-English First

Imagine renovating a house room by room while still living in it. You can install a new kitchen while the old bedroom is still in use — but you cannot have two doors to the same room from different hallways. That's the coexistence rule: both routers can exist in the same project, but they cannot compete for the same URL path. The overlapping route is the door that leads to 404.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

Migrating from the Pages Router to the App Router is the most common large-scale Next.js migration project. It's not a simple upgrade — the two routing systems have fundamentally different mental models. The Pages Router treats every file as a page component with explicit data-fetching functions. The App Router treats every file as a server-rendered component with async data fetching.

Both routers can coexist in the same Next.js project. This is intentional — Vercel designed the App Router to support incremental adoption. You can add an app/ directory alongside your existing pages/ directory and both work together, routing traffic based on the filesystem hierarchy. The critical rule: routes cannot overlap. If pages/api/user.js exists, you cannot have app/api/user/route.js — one will silently return 404.

In a recent production incident, a team running a mixed router deployment discovered that all API routes were returning 404. The cause: they had added an app/api/ directory for a new endpoint, but an existing pages/api/ directory had the same route structure. The App Router took precedence for the overlapping paths — and returned 404 because the App Router route handler returned the wrong response format.

This article covers the complete migration strategy: coexistence rules, incremental adoption patterns, data fetching migration (getServerSideProps/getStaticProps to Server Component fetch), layout nesting differences, route groups, parallel routes, and the production incident that happens when you get the route structure wrong.

Coexistence Rules — Both Routers Can Live Together, BUT

Next.js supports running both the Pages Router and the App Router in the same project. When you add an app/ directory alongside pages/, both are active. Next.js checks the app/ directory first for a matching route. If found, the App Router handles it. If not found, Next.js falls back to the Pages Router.

The critical rule: a route can be handled by ONLY ONE router. If /blog/hello exists in both pages/blog/[slug].js and app/blog/[slug]/page.tsx, the App Router version takes precedence — the Pages Router version is completely ignored, even if the App Router version throws an error or returns 404.

This means: during migration, every route must exist in exactly ONE router. You cannot have the same route partially migrated. The approach is to migrate routes one at a time: remove the pages/ version AND add the app/ version in the same deploy. For the duration of the migration, both routers handle different sets of routes.

The API route conflict (the production incident described above) is the most dangerous version of this rule. Because API routes don't have a visual presence, a 404 from a missing HTTP method export can go unnoticed for days. The fix: migrate all API routes in a single batch, not incrementally.

RouteConflictCheckTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Check for overlapping routes between pages/ and app/
# Run BEFORE any migration deploy to detect conflicts

# List all routes from both directories
find app pages -type f \( -name 'page.tsx' -o -name 'route.ts' -o -name '*.js' \) \
  | sed 's|^./||' \
  | sed 's|/page\.tsx$||' \
  | sed 's|/route\.ts$||' \
  | sed 's|\.js$||' \
  | sed 's|/index$||' \
  | sort \
  | uniq -d

# If this outputs any paths, you have overlapping routes.
# The App Router version will take precedence.
# Example output:
# ```
# api/users
# blog/[slug]
# ```
# Means both app/api/users/route.ts (or page.tsx)
# AND pages/api/users.js exist — a conflict.
Try it live
Pre-Migration Route Audit
Before starting migration, run a route conflict check. Find every path that exists in both pages/ and app/. Decide which router handles each conflicting path. Migrate the path completely (remove from pages/ AND add to app/) before deploying. Never deploy with known route overlaps.
Production Insight
A team spent 2 weeks migrating routes to App Router but kept API routes in the Pages Router 'for later'. During those 2 weeks, any new page in app/ that had the same path as a pages/ API route silently broke the API endpoint. Three API routes were affected before the team realised the overlap. Rule: API routes should be migrated first, before page routes. The silent 404 nature of API route conflicts means they cause the most damage before discovery.
Key Takeaway
Both routers coexist but routes CANNOT overlap — a path can exist in only ONE router at a time.
App Router takes precedence over Pages Router for overlapping paths — no fallback, no merge.
Run a route conflict check before every migration deploy. Migrate API routes FIRST, in one batch.
nextjs-pages-to-app-router-migration THECODEFORGE.IO Mixed Router Architecture Layers How Pages and App Routers coexist in Next.js Routing Layer Pages Router | App Router Data Fetching getServerSideProps | Server Components Layout System Per-page Layouts | Nested Layouts Middleware pages/_middleware | app/middleware Error Handling Custom Error Pages | error.js THECODEFORGE.IO
thecodeforge.io
Nextjs Pages To App Router Migration

Incremental Migration Strategy — Pages One at a Time

The correct migration strategy is NOT to rewrite the entire app. It's to migrate one route at a time, maintaining both routers in parallel until every route is migrated. The strategy has four phases.

Phase 1 — Foundation: Create the app/ directory with a root layout.tsx. This layout must include global CSS imports, fonts, and context providers that were previously in _app.tsx and _document.tsx. Without this step, migrated pages will render without styles or providers.

Phase 2 — API Routes: Migrate all API routes from pages/api/ to app/api/ in a single batch. Since API routes have no UI and no layout nesting, they are the easiest to migrate and the most dangerous to leave mixed. Export all HTTP methods as named exports.

Phase 3 — Leaf Pages: Migrate the most deeply nested pages first (e.g., /blog/[slug] before /blog). Leaf pages depend on layouts, not the other way around. Migrating leaves first means you can test each page independently. Each leaf page becomes a Server Component with direct async fetch instead of getServerSideProps.

Phase 4 — Layouts: Migrate layouts last. The App Router's nested layout system is a significant architectural change. Migrate from bottom to top: migrate the innermost layout first, then the parent layout, then the root layout. Each layout migration should be a separate PR.

MigrationChecklistTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Phase 1: Root Layout
// app/layout.tsx — must mirror _app.tsx + _document.tsx
// Without this, migrated pages have no styles

// Phase 2: API Routes (ALL at once)
// pages/api/users.js  →  app/api/users/route.ts
// pages/api/posts.js  →  app/api/posts/route.ts

// Phase 3: Leaf Pages (one at a time)
// pages/blog/[slug].js  →  app/blog/[slug]/page.tsx
// Steps:
//   1. Create app/blog/[slug]/page.tsx
//   2. Convert getStaticProps → fetch with revalidate
//   3. Convert getStaticPaths → generateStaticParams
//   4. Remove pages/blog/[slug].js

// Phase 4: Layouts (bottom-up)
// pages/blog.js (layout for /blog/*)  →  app/blog/layout.tsx
// pages/_app.tsx (global layout)      →  app/layout.tsx (already done in Phase 1)

// NEVER do in one PR:
// - Migrate more than 1-2 leaf pages
// - Leave overlapping routes
// - Skip Phase 1 (root layout without styles)
Try it live
One Page Per PR
Migrate exactly 1-2 leaf pages per PR. Each PR should be small enough that if it breaks, you can identify the cause immediately. Large migration PRs are nearly impossible to debug — the error could be in any of 20 migrated pages. If you must batch, batch by route segment (e.g., all /blog/ pages in one PR, all /dashboard/ in another).
Production Insight
A team attempted to migrate all 47 pages in a single PR. The build failed with 23 errors from 6 different categories: missing layout providers, incorrect generateStaticParams, wrong fetch caching, and route conflicts. Debugging took 3 days and the PR was abandoned. The successful attempt: 12 PRs over 3 weeks, each migrating 2-4 pages. Rule: the incremental approach is slower upfront but faster overall — because each small PR is easy to debug and unlikely to cascade failures across the app.
Key Takeaway
Migrate in 4 phases: root layout → API routes (batch) → leaf pages (one at a time) → layouts (bottom-up).
One-to-two leaf pages per PR — small PRs are debuggable, large migration PRs are a 3-day debugging effort.
API routes must be migrated first and in one batch — mixed API routes cause silent 404s.

Data Fetching Migration — getServerSideProps to Server Component Fetch

The most dramatic change between routers is data fetching. In the Pages Router, data fetching is decoupled from the component via getServerSideProps (SSR) and getStaticProps (SSG/ISR). In the App Router, data fetching happens directly inside Server Components using async/await with fetch().

Migration mapping: getServerSidePropsfetch(url, { cache: 'no-store' }). The no-store cache option tells Next.js not to cache the response — equivalent to SSR. getStaticPropsfetch(url, { next: { revalidate: N } }). The revalidate option enables ISR with N-second background regeneration. getStaticProps without revalidate → fetch(url). Default fetch is static (no caching directives) — equivalent to SSG.

The critical difference: in the Pages Router, getStaticProps returned { props: { ... } } and the component received those props. In the App Router, you call fetch directly in the component body — there are no props. This means error handling, loading states, and null checks must happen inside the component, not in a separate data fetching function.

For pages that need getStaticPaths (dynamic routes with pre-built paths), the App Router equivalent is generateStaticParams. It returns an array of param objects directly (no { paths, fallback } wrapper). The fallback behaviour is replaced by dynamicParams: true (equivalent to fallback: true) or false (equivalent to fallback: false) exported from the page.

migration-data-fetching.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ===== PAGES ROUTER (BEFORE) =====

import type { GetServerSideProps, GetStaticProps } from 'next'

// SSR — fresh data on every request
export const getServerSideProps: GetServerSideProps = async () => {
  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()
  return { props: { posts } }
}

export default function Page({ posts }: { posts: any[] }) {
  return <div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>
}

// ===== APP ROUTER (AFTER) =====

// SSR — fresh data on every request
// Default export is async — fetch directly in component
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'no-store', // Equivalent to getServerSideProps
  })
  const posts = await res.json()

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  )
}

// ===== ISR MIGRATION =====

// Pages Router: getStaticProps with revalidate
export const getStaticProps: GetStaticProps = async () => {
  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()
  return { props: { posts }, revalidate: 3600 }
}

// App Router: fetch with next.revalidate
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 3600 }, // Equivalent to ISR
  })
  const posts = await res.json()
  return <div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>
}
Try it live
from Props to Direct Variables
The biggest mental shift: in Pages Router, data is 'outside' the component (in getServerSideProps) and injected as props. In App Router, data is 'inside' the component — you fetch it, await it, and use it in the same async function. This is simpler but requires changing error handling — try/catch inside the component instead of returning error props.
Production Insight
A team migrating a product listing page forgot to add cache: 'no-store' to their fetch while migrating from getServerSideProps. The page became statically generated at build time — product listings did not update for 24 hours until the next deploy. The fix: adding cache: 'no-store' restored per-request freshness. Rule: when migrating from getServerSideProps, you MUST explicitly set cache: 'no-store'. Default fetch behaviour is static — it will appear to work in development (where every request re-fetches) but cache in production.
Key Takeaway
getServerSideProps → fetch(url, { cache: 'no-store' }). getStaticProps → fetch(url) or fetch(url, { next: { revalidate: N } }).
getStaticPaths → generateStaticParams (returns array of params, no { paths, fallback } wrapper).
Default fetch() is static — always specify cache strategy when migrating from SSR.
Pages Router vs App Router Key differences in routing and data handling Pages Router App Router Routing File-based pages/ File-based app/ with route groups Data Fetching getServerSideProps Server Components and async Layouts Per-page layout Nested layouts with layout.js Middleware _middleware.ts in pages middleware.ts in root Error Handling Custom _error.js error.js boundary THECODEFORGE.IO
thecodeforge.io
Nextjs Pages To App Router Migration

Layout Nesting — The Architectural Game Changer

The Pages Router has a flat layout system: _app.tsx (wraps every page), _document.tsx (controls HTML document structure). Every page shares the same layout hierarchy. If you need different layouts for different sections, you must build a custom layout system with conditional rendering.

The App Router introduces nested layouts: every directory can have a layout.tsx that wraps all pages in that directory and its subdirectories. This is the most architecturally significant difference between the two routers.

Example: /blog/[slug]/page.tsx is wrapped by app/blog/[slug]/layout.tsx (if it exists), which is wrapped by app/blog/layout.tsx, which is wrapped by app/layout.tsx. Each layout persists across navigations within its segment — the sidebar in /blog/layout.tsx remains mounted when navigating between blog posts.

The migration challenge: your existing _app.tsx likely contains providers (Theme, Auth, QueryClient, Toaster) and global styles. These must be moved to app/layout.tsx. If you have section-specific layouts built with custom code in the Pages Router, those become nested layout files in the App Router.

The production insight: App Router layouts are Server Components by default. If your layout needs interactivity (a sidebar with user state, a navigation with client-side routing), you need to add 'use client' to the layout or extract interactive parts into client subcomponents. However, making a layout a client component means ALL its children (every page in that segment) are also client-rendered — defeating the Server Component benefit.

Layout MigrationTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ===== PAGES ROUTER LAYOUTS =====
// pages/_app.tsx — wraps ALL pages globally

import type { AppProps } from 'next/app'
import { ThemeProvider } from '@/providers/theme'
import { AuthProvider } from '@/providers/auth'
import '@/styles/globals.css'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <Component {...pageProps} />
      </AuthProvider>
    </ThemeProvider>
  )
}

// ===== APP ROUTER LAYOUTS =====
// app/layout.tsx — root layout, wraps ALL app/ pages

import { ThemeProvider } from '@/providers/theme'
import { AuthProvider } from '@/providers/auth'
import '@/styles/globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <ThemeProvider>
          <AuthProvider>
            {children}
          </AuthProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

// app/blog/layout.tsx — wraps only /blog/* pages
// This sidebar persists across blog post navigations

import { Sidebar } from '@/components/sidebar'

export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <Sidebar />    {/* Stays mounted between posts */}
      <main>{children}</main>
    </div>
  )
}
Try it live
Don't Make Root Layout a Client Component
Adding 'use client' to your root layout.tsx forces every page in the app to be client-rendered — you lose the Server Component performance benefit. Keep the root layout as a Server Component. Extract interactive parts (navigation, sidebar) into separate client components that are imported into the layout.
Production Insight
A team migrated their _app.tsx (which had 'use client' implicitly — all Pages Router components are client-rendered) into their root layout.tsx by adding 'use client' at the top. Every page in the App Router became a Client Component. JS bundle size increased by 40%. The fix: removing 'use client' from layout.tsx, keeping providers in a separate client component file, and importing them into the server layout. Rule: root layout MUST be a Server Component. Client-side providers go in a separate client component that the server layout wraps.
Key Takeaway
App Router uses nested layouts (layout.tsx per directory) — unlike Pages Router's global _app.tsx.
Root layout must be a Server Component — extract client providers (Theme, Auth) into separate client components.
Layouts persist across navigations within their segment — the sidebar in /blog/layout.tsx stays mounted between blog posts.

Route Groups and Parallel Routes — New Patterns, No Pages Router Equivalent

The App Router introduces two routing patterns that have no Pages Router equivalent: Route Groups and Parallel Routes.

Route Groups: directories named in parentheses (group) that do NOT affect the URL path. Use them to organise routes without changing the URL structure. For example: app/(marketing)/about/page.tsx renders at /about, not /(marketing)/about. The (marketing) group is purely organisational — it can have its own layout that applies only to marketing pages.

Parallel Routes: directories named with @ prefix that render multiple independent segments on the same page. For example, a dashboard with a main content area and a sidebar — each can be a separate file that renders independently. Parallel routes enable complex layouts like modals that appear alongside the underlying page, where the modal content is a separate route segment.

Migration strategy: you do not need to use these patterns during migration. They are additive — adopt them for new features built in the App Router. Do not try to replicate them in the Pages Router. When migrating existing pages, keep the same URL structure — use Route Groups only if you need different layouts for different sections at the same URL level.

RouteGroupPatternTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ===== ROUTE GROUPS =====
// Directory structure (URLs in comments):

// app/
//   (marketing)/          ← No URL segment! Just for layout organisation
//     layout.tsx           ← Layout for all marketing pages
//     page.tsx             ← /
//     about/page.tsx       ← /about
//     pricing/page.tsx     ← /pricing
//   (dashboard)/           ← No URL segment
//     layout.tsx           ← Different layout for dashboard pages
//     page.tsx             ← /dashboard
//     settings/page.tsx    ← /dashboard/settings

// ===== PARALLEL ROUTES =====
// app/dashboard/
//   layout.tsx             ← Contains both @sidebar and @main slots
//   @sidebar/
//     page.tsx              ← Renders in the 'sidebar' slot
//   @main/
//     page.tsx              ← Renders in the 'main' slot

// app/dashboard/layout.tsx
// export default function DashboardLayout({
//   sidebar,  ← Content from @sidebar
//   main,     ← Content from @main
// }: {
//   sidebar: React.ReactNode
//   main: React.ReactNode
// }) {
//   return (
//     <div className="flex">
//       <aside>{sidebar}</aside>
//       <main>{main}</main>
//     </div>
//   )
// }
Try it live
Don't Overcomplicate the Migration
Route Groups and Parallel Routes are powerful but not required for migration. Start with the simplest App Router patterns that replicate your existing Pages Router structure. Add Route Groups only when you need different layouts for different sections. Add Parallel Routes only for complex dashboard layouts or modal patterns. The priority is shipping the migration, not adopting every new feature.
Production Insight
A team spent 2 weeks trying to replicate their Pages Router's custom layout system using App Router Parallel Routes. The original system was a simple conditional layout based on URL path — something a single Route Group with two layouts would have solved in 1 hour. Rule: use the simplest App Router pattern that matches your existing Pages Router behaviour. Parallel Routes are for new complex layouts, not for replicating simple conditional layouts.
Key Takeaway
Route Groups (parentheses dirs) organise routes without changing URLs — useful for section-specific layouts.
Parallel Routes (@-prefixed dirs) render multiple independent segments on the same page — for complex dashboards and modals.
Don't overcomplicate migration — use the simplest App Router patterns that replicate your existing structure.

Error and Loading States — From Custom Handlers to Conventions

The Pages Router handles loading and error states manually: a isLoading state in the component, try/catch in getServerSideProps with error props, and _error.js for global errors. The App Router introduces file-convention-based error and loading boundaries.

loading.tsx: place a loading component in any directory. Next.js automatically wraps the page content in a Suspense boundary and shows the loading component while the page's async data is being fetched. No manual loading state management needed.

error.tsx: place an error component in any directory. Next.js automatically wraps the content in an ErrorBoundary. The error component receives error and reset props — reset allows the user to retry the failed render. Error boundaries are per-directory — an error in app/blog/[slug]/page.tsx shows the error component from app/blog/[slug]/error.tsx, not the root error.

not-found.tsx: shown when notFound() is called from a Server Component or when the URL does not match any route. Replaces the custom 404 page in the Pages Router.

Migration: move your loading spinner JSX to loading.tsx in the appropriate directory. Move your error UI to error.tsx. Move your custom 404 page to not-found.tsx. The convention-based approach eliminates the boilerplate of managing loading/error state in every component.

ErrorLoadingPatternTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// ===== loading.tsx — Auto-wrapped in Suspense =====
// app/blog/loading.tsx — shown when any /blog/* page is loading

export default function BlogLoading() {
  return (
    <div className="space-y-4 animate-pulse">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="h-24 bg-gray-200 rounded-lg" />
      ))}
    </div>
  )
}

// ===== error.tsx — Per-segment error boundary =====
// app/blog/[slug]/error.tsx — only for individual blog post errors
'use client' // Error boundaries must be Client Components

export default function PostError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="text-center py-12">
      <h2>Failed to load post</h2>
      <p className="text-gray-500">{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

// ===== not-found.tsx — 404 page =====
// app/not-found.tsx — shown when notFound() is called

export default function NotFound() {
  return (
    <div className="text-center py-20">
      <h1 className="text-4xl font-bold">404</h1>
      <p className="text-gray-500">Page not found</p>
    </div>
  )
}
Try it live
Error Boundaries Must Be Client Components
error.tsx requires 'use client' because Error Boundaries use the componentDidCatch lifecycle, which is a client-side feature. loading.tsx and not-found.tsx do NOT need 'use client' — they can be Server Components. This is a common gotcha: error.tsx will silently fail if you forget 'use client'.
Production Insight
A team migrated error handling and added a beautiful loading.tsx with skeleton screens. They forgot that error.tsx must be a Client Component. The error boundary never rendered — errors in the blog post page showed a blank white page with no error message, no retry button, and no console error. Users saw a broken page with no way to recover. The fix: adding 'use client' to error.tsx. Rule: error.tsx ALWAYS needs 'use client'. loading.tsx and not-found.tsx do not.
Key Takeaway
loading.tsx = auto-Suspense wrapper (no 'use client' needed). error.tsx = error boundary (MUST have 'use client'). not-found.tsx = 404 page (no 'use client' needed).
Error boundaries are per-segment — an error in one part of the app doesn't crash the entire page.
The convention-based approach eliminates manual loading/error state management.

Middleware Migration — Same Concept, New Location

Middleware in the Pages Router lives at pages/_middleware.js (older versions) or middleware.ts at the project root (Next.js 12+). In the App Router, middleware still lives at the project root — the location is the same. The API and behaviour are also similar, with minor differences.

Both routers share the same middleware file: middleware.ts at the project root. The middleware runs on every request, before the router determines whether the Pages Router or App Router handles it. Middleware cannot know which router will handle the request — it operates at the URL level.

The migration concern: if your middleware modifies the URL (rewrites, redirects, adding headers), it affects both routers equally — there is no 'Pages Router middleware' vs 'App Router middleware'. If your middleware references Pages Router-specific patterns (like checking for __nextLocale query params), those may behave differently when the App Router handles the request.

middleware.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// middleware.ts — SAME FILE for both routers
// Located at project root, NOT inside pages/ or app/

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Example: redirect old blog URLs (affects both routers)
  if (pathname.startsWith('/old-blog')) {
    const slug = pathname.replace('/old-blog/', '')
    return NextResponse.redirect(
      new URL(`/blog/${slug}`, request.url)
    )
  }

  // Example: add security headers (affects both routers)
  const response = NextResponse.next()
  response.headers.set('X-Robots-Tag', 'index, follow')

  return response
}

// Matcher config — applies to both routers
export const config = {
  matcher: [
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}
Try it live
Middleware Is Router-Agnostic
Middleware runs before the router decision. It cannot know whether the request will be handled by the Pages or App Router. This is a feature: middleware provides consistent request processing (auth, redirects, headers) regardless of the routing system. Do NOT put router-specific logic in middleware.
Production Insight
A team's middleware had request.nextUrl.searchParams.get('slug') that expected the Pages Router's query parameter format. After migrating the route to App Router, the parameter format changed slightly — the App Router passes params differently in rewrites. The middleware broke for migrated routes even though the URL looked the same. Rule: middleware should operate on URL path and headers only — not on query parameter formats that differ between routers.
Key Takeaway
Middleware is shared between both routers — same file (middleware.ts at project root), same behaviour.
Middleware runs before the router decision — it cannot know which router handles the request.
Avoid router-specific patterns in middleware (Pages Router query param formats, custom headers).

Testing the Mixed Router State — CI Must Check Both

During migration, your CI must test both the Pages Router routes and the App Router routes independently. A test that passes on a Pages Router route does not guarantee the App Router equivalent will work — different data fetching, different error handling, different rendering behaviour.

The testing strategy during migration: (1) Unit tests for data fetching — test that fetch with cache: 'no-store' returns the same data as getServerSideProps. (2) Integration tests for routes — for every migrated route, assert the App Router version returns the same HTTP status, content, and headers as the Pages Router version. (3) Route conflict checks — a script that detects overlapping routes (described in the first section) must pass before every deploy. (4) Full build — next build must succeed. A successful build guarantees no route conflicts (they cause build errors).

The end state: when all routes are migrated, remove the pages/ directory entirely. Verify that the App Router handles all routes by running the same test suite against the migrated app. Only then can you delete _app.tsx, _document.tsx, and any Pages Router-specific code.

__tests__/migration.test.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Integration test: verify both routers serve the same content
// Run during migration period to catch regressions

async function fetchFromRouter(path: string) {
  const res = await fetch(`http://localhost:3000${path}`)
  return {
    status: res.status,
    body: await res.text(),
    headers: Object.fromEntries(res.headers.entries()),
  }
}

describe('Migration parity', () => {
  const routes = [
    '/',
    '/blog',
    '/blog/first-post',
    '/about',
    '/api/users',
    '/api/posts',
  ]

  for (const route of routes) {
    it(`${route} returns 200`, async () => {
      const { status } = await fetchFromRouter(route)
      expect(status).toBe(200)
    })

    it(`${route} contains expected content`, async () => {
      const { body } = await fetchFromRouter(route)
      // Assert content exists (not empty 404 page)
      expect(body.length).toBeGreaterThan(100)
    })
  }

  // Special test for API routes — check all methods
  it('API routes handle POST', async () => {
    const res = await fetch('http://localhost:3000/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'test' }),
    })
    expect(res.status).not.toBe(404)
    // 4xx is acceptable (validation error) — 404 is not
  })
})
Try it live
Test ALL HTTP Methods on API Routes
A 404 on GET for an API route is immediately obvious. A 404 on POST/PUT/DELETE may go unnoticed for days because: (1) most monitoring only tracks GET, (2) staging tests typically only test GET, (3) the error response is a standard 404 with no indication that the route was 'almost' working. Always test every HTTP method on every API route.
Production Insight
The 3-day API route 404 incident described in the production incident section happened because: (1) staging tests only tested GET endpoints, (2) production monitoring did not differentiate 404s by HTTP method, (3) the POST and PUT 404s were attributed to an unrelated database migration happening simultaneously. Rule: test every endpoint with every HTTP method. Monitor 404s by path AND method. Never deploy API routes without multi-method testing.
Key Takeaway
Test both routers independently during migration — Pages Router tests + App Router tests for the same routes.
Test every API route with every HTTP method (GET, POST, PUT, DELETE) — missing method exports cause silent 404s.
Route conflict checks must pass before every deploy during migration — use a script that detects overlapping paths.

The Final State — Cleaning Up After Migration

When every route has been migrated to the App Router, the final step is cleanup. The pages/ directory can be deleted. Remove Pages Router-specific dependencies (getInitialProps, getServerSideProps, getStaticProps wrappers, next/router, _app.tsx, _document.tsx).

The confirmation step: run next build after removing pages/. A successful build with no 'Route' entries from the old pages directory confirms the migration is complete. The next build output should show all routes under ○ (Static) or λ (Dynamic) with app/ prefix — no pages/-prefixed routes.

Common leftover issues: (1) Imports from next/router in migrated components — useRouter must be replaced with useSelectedLayoutSegment or usePathname from next/navigation. (2) _app.tsx providers not moved to root layout — if migrated pages lack styling or context. (3) Pages Router API route patterns (req.query, res.status) still used in migrated route handlers — the App Router uses Web Request/Response objects.

Final verification: all routes return 200, all API endpoints handle all methods, all layouts render correctly, and styles are consistent.

MigrationCleanupBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Step 1: Verify no routes remain in pages/
# Run this after removing pages/ directory
next build 2>&1 | grep -E 'Route|pages/' | head -20
# If this shows 'pages/' anywhere, you missed some routes

# Step 2: Check for Pages Router imports in the App Router
# These imports indicate incomplete migration

grep -rn "from 'next/router'\|"from 'next/router'" app/ --include='*.{tsx,ts}' || echo "No next/router imports"
grep -rn "getServerSideProps\|getStaticProps" app/ --include='*.{tsx,ts}' || echo "✅ No getServerSideProps/getStaticProps"
grep -rn "useRouter\|withRouter" app/ --include='*.{tsx,ts}' || echo "✅ No useRouter"

# Step 3: Check for Pages Router API patterns in app/api/
grep -rn "req\.query\|res\.status\|res\.json" app/api/ --include='*.{ts,tsx}' || echo "✅ No Pages Router API patterns"

# Step 4: Final verification
# Run the full test suite and check all routes return 200
npm run build && npm run test
Pages Router Import Detector
Create a lint rule or CI check that detects Pages Router-specific imports (next/router, getServerSideProps, getStaticProps, getInitialProps) in the app/ directory. This catches residual Pages Router patterns that slipped through. Run this check after every migration PR.
Production Insight
A team declared their migration complete but a leftover _app.tsx file (with a 'use client' provider) was still wrapping all App Router pages through an outdated import. Removing _app.tsx broke the production site — the AuthProvider was only in _app.tsx, not in the root layout. They had missed the provider migration step. Rule: the migration is not complete until: (1) pages/ is deleted, (2) all root layout providers match _app.tsx, (3) next build shows zero references to the old router patterns.
Key Takeaway
Delete pages/ directory only after ALL routes are migrated. Verify with next build output.
Replace next/router imports with next/navigation equivalents (usePathname, useSearchParams).
Verify root layout providers match _app.tsx — missing providers cause lost styling and context.
● Production incidentPOST-MORTEMseverity: high

API Routes Returned 404 for 3 Days — A Mixed Router Conflict

Symptom
All POST requests to /api/users returned 404 after a deploy that added the App Router alongside Pages Router. GET requests to the same endpoint worked fine. The incident lasted 3 days because: (1) the staging environment only tested GET, (2) production monitoring did not track 404s by HTTP method, (3) the incident coincided with a database migration, so the 404s were attributed to database connection issues.
Assumption
The team assumed that adding an App Router API route at the same path as an existing Pages Router API route would override only the GET handler (which they wanted to rewrite). They did not realise that App Router API routes require named exports for EVERY HTTP method — omitting POST exports causes a 404 for that method, not a fallback to the Pages Router handler. They also assumed that the two routers would 'merge' handlers for the same path — they do not.
Root cause
The Pages Router had pages/api/users.js with a default export that handled GET, POST, and PUT via a switch statement. The team created app/api/users/route.ts with only a GET export: export async function GET() { ... }. In a mixed router setup, App Router routes take precedence over Pages Router routes for the same path. The App Router's route.ts handled GET correctly (matched export) but returned 404 for POST and PUT (no matching export). The Pages Router's handler was completely ignored because the App Router claimed the /api/users path. The fix: (1) moved all API routes from pages/api/ to app/api/ in one batch — no mixed API routes. (2) Added named exports for all HTTP methods in each route.ts. (3) Added a CI test that sends GET, POST, PUT, DELETE requests to every API route and asserts each returns 200 or 4xx (not 404).
Fix
Created app/api/users/route.ts with GET, POST, PUT, and DELETE named exports. Moved all existing API routes from pages/api/ to app/api/ in a single PR (no mixed API route state). Added a CI integration test that calls every API endpoint with every HTTP method and asserts non-404 responses. Added a monitoring check that alerts if any API route returns >1% 404 responses for any HTTP method.
Key lesson
  • App Router API routes take precedence over Pages Router API routes for the same path — there is NO fallback
  • App Router route.ts requires named exports for EVERY HTTP method (GET, POST, PUT, PATCH, DELETE) — missing exports return 404
  • Do NOT run mixed API routes during migration — migrate all API routes in one batch to avoid route conflicts
  • Test every API endpoint with EVERY HTTP method in CI — not just the most common method
Production debug guideDiagnose migration issues, route conflicts, and data fetching regressions4 entries
Symptom · 01
API routes return 404 after adding app/ directory
Fix
Check for overlapping routes between pages/api/ and app/api/. App Router takes precedence for overlapping paths. Verify that each route.ts file has named exports for all required HTTP methods. Use curl -X POST (not just GET) to test each endpoint
Symptom · 02
Page content differs between Pages Router and App Router versions of the same route
Fix
Compare the data fetching logic: Pages Router uses getServerSideProps/getStaticProps with explicit props. App Router uses async/await fetch() in Server Components. Ensure the fetch URL, caching strategy (revalidate, cache), and error handling are identical. Add logging to both implementations and compare the fetched data
Symptom · 03
Layout styles break after migrating a page to App Router
Fix
App Router layouts are nested per directory — not global like _app.tsx. If the old _app.tsx had global CSS imports or providers, you need to add them to the root layout.tsx. Check that fonts, global CSS, and context providers are re-created in the App Router layout hierarchy
Symptom · 04
getStaticPaths fails after partial migration
Fix
Check that generateStaticParams (App Router equivalent) is called with the correct parameters. The function signature changed: getStaticPaths returns { params: { ... }[] } while generateStaticParams returns an array of param objects directly. Also check that generateStaticParams is in the page.tsx file, not a separate file
★ Pages to App Router Migration Quick Debug ReferenceFast commands for diagnosing migration issues
Route returns 404 — route overlap between pages/ and app/
Immediate action
Find overlapping route definitions
Commands
find pages app -name '*.tsx' -o -name '*.ts' -o -name '*.js' | sed 's|^./||' | sort
next build 2>&1 | grep -E 'Route|page|api' | head -30
Fix now
Remove the overlapping route from pages/ directory. App Router takes precedence — ensure the app/ version handles all HTTP methods
Page renders without styles after migration+
Immediate action
Check if global CSS and providers are in the root layout
Commands
cat app/layout.tsx | head -30
grep -rn "import.*css" app/layout.tsx app/layout.js
Fix now
Add global CSS imports and context providers (ThemeProvider, AuthProvider) to app/layout.tsx. The _app.tsx is no longer used for app/ directory pages
getServerSideProps data not available in App Router page+
Immediate action
Convert getServerSideProps to Server Component fetch
Commands
grep -rn 'getServerSideProps' pages/ --include='*.tsx' --include='*.js'
grep -rn 'fetch' app/ --include='*.tsx' --include='*.ts' -A3 | head -20
Fix now
Replace getServerSideProps with async component: export default async function Page() { const data = await fetch(url, { cache: 'no-store' }); ... }. The props from getServerSideProps become direct variables
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
RouteConflictCheckfind app pages -type f \( -name 'page.tsx' -o -name 'route.ts' -o -name '*.js' \...Coexistence Rules
migration-data-fetching.tsxexport const getServerSideProps: GetServerSideProps = async () => {Data Fetching Migration
Layout Migrationexport default function App({ Component, pageProps }: AppProps) {Layout Nesting
ErrorLoadingPatternexport default function BlogLoading() {Error and Loading States
middleware.tsexport function middleware(request: NextRequest) {Middleware Migration
__tests__migration.test.tsasync function fetchFromRouter(path: string) {Testing the Mixed Router State
MigrationCleanupnext build 2>&1 | grep -E 'Route|pages/' | head -20The Final State

Key takeaways

1
Both routers coexist but routes CANNOT overlap
App Router takes precedence, no fallback, no merge. Run a conflict check before every migration deploy
2
Migrate in 4 phases
root layout first → API routes (one batch) → leaf pages (one at a time, bottom-up) → layouts (bottom-up)
3
Data fetching migration
getServerSideProps → fetch(url, { cache: 'no-store' }); getStaticProps → fetch(url) or fetch(url, { next: { revalidate: N } })
4
API routes must be migrated first and tested with every HTTP method
missing POST/PUT/DELETE exports cause silent 404s that go unnoticed for days
5
Root layout must be a Server Component
extract client providers (Auth, Theme) into separate client components imported by the server layout
6
Loading and error states use file conventions
loading.tsx (no 'use client' needed), error.tsx (MUST have 'use client'), not-found.tsx (no 'use client' needed)
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
You are leading a migration of a 50-page Next.js app from Pages Router t...
Q02SENIOR
A team migrates a route to the App Router but forgets to remove the Page...
Q03SENIOR
Explain the architectural differences between Pages Router layouts (_app...
Q04SENIOR
Your team deploys a mixed router project and discovers that API routes r...
Q01 of 04SENIOR

You are leading a migration of a 50-page Next.js app from Pages Router to App Router. Design the migration strategy, including the order of operations, how you handle the coexistence period, and how you prevent production incidents.

ANSWER
The migration follows a 4-phase strategy over approximately 4-6 weeks. Phase 1 — Foundation (Week 1): Create the app/ directory with a root layout.tsx. This layout must include all global CSS imports, fonts, and context providers from _app.tsx and _document.tsx. Add loading.tsx and error.tsx to the root for basic error handling. Deploy this without migrating any pages — the root layout has no effect until App Router pages exist. This phase validates that the build process works with both routers. Phase 2 — API Routes (Week 1, after Phase 1): Migrate all API routes from pages/api/ to app/api/ in a single PR. This is the highest-risk phase because overlapping API routes cause silent 404s. Create named exports (GET, POST, PUT, DELETE) for every route. Add CI tests that verify every API route handles every HTTP method. Deploy this only after thorough testing. Phase 3 — Leaf Pages (Weeks 2-4): Migrate 2-4 leaf pages per week, bottom-up (most nested routes first). Each PR: (1) create the App Router page file, (2) convert getServerSideProps/getStaticProps to async fetch, (3) add error.tsx and loading.tsx for the page's segment, (4) remove the old Pages Router page. Run the route conflict checker before each deploy. Phase 4 — Layouts (Weeks 4-6): Migrate layouts bottom-up — innermost layout first, root layout last. This is the most architecturally significant phase because App Router layouts are nested (layout.tsx per directory) vs Pages Router's global _app.tsx. Incident prevention: (1) route conflict checker as a CI gate — fails if any path exists in both routers. (2) Multi-method API route tests. (3) Content parity tests — the App Router version of a page should render the same content as the Pages Router version. (4) Rollback plan — if an incident occurs, revert the last migration PR (the old Pages Router page still exists in Git history and can be restored in minutes).
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Can I run both Pages Router and App Router in production at the same time?
02
How do I migrate getServerSideProps to the App Router?
03
What happens to _app.tsx and _document.tsx when I migrate to the App Router?
04
How do I migrate getStaticPaths to the App Router?
05
Why do my App Router API routes return 404 for POST requests?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

7 min read · try the examples if you haven't

Previous
next.config.ts Deep Dive: All Configuration Options Explained
49 / 56 · Next.js
Next
Next.js Project Structure: Folder Organization Best Practices