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.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓Node.js 18+
- ✓Existing Next.js Pages Router project
- ✓Understanding of both routing systems
- Both routers CAN coexist in one Next.js project — but routes CANNOT overlap. A
pages/api/user.jsandapp/api/user/route.jswill 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() withcache: 'no-store'in Server Components;getStaticProps→ fetch() withnext: { revalidate: N } - Layout nesting is the biggest architectural difference: App Router uses nested
layout.tsxfiles per folder; Pages Router uses_app.tsxand_document.tsxglobally - The migration order matters: migrate leaf pages first (most nested routes), then layouts, then root layout last. This prevents cascading changes
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.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
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.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.
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: getServerSideProps → fetch(url, { cache: 'no-store' }). The no-store cache option tells Next.js not to cache the response — equivalent to SSR. getStaticProps → fetch(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.
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.fetch() is static — always specify cache strategy when migrating from SSR.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.
_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.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.
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.
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.
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.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.
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.
_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.API Routes Returned 404 for 3 Days — A Mixed Router Conflict
/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.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.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).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.- 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
curl -X POST (not just GET) to test each endpointfetch() 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 datafind pages app -name '*.tsx' -o -name '*.ts' -o -name '*.js' | sed 's|^./||' | sortnext build 2>&1 | grep -E 'Route|page|api' | head -30| File | Command / Code | Purpose |
|---|---|---|
| RouteConflictCheck | find app pages -type f \( -name 'page.tsx' -o -name 'route.ts' -o -name '*.js' \... | Coexistence Rules |
| migration-data-fetching.tsx | export const getServerSideProps: GetServerSideProps = async () => { | Data Fetching Migration |
| Layout Migration | export default function App({ Component, pageProps }: AppProps) { | Layout Nesting |
| ErrorLoadingPattern | export default function BlogLoading() { | Error and Loading States |
| middleware.ts | export function middleware(request: NextRequest) { | Middleware Migration |
| __tests__ | async function fetchFromRouter(path: string) { | Testing the Mixed Router State |
| MigrationCleanup | next build 2>&1 | grep -E 'Route|pages/' | head -20 | The Final State |
Key takeaways
Interview Questions on This Topic
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.
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).Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's Next.js. Mark it forged?
7 min read · try the examples if you haven't