Ch. 24 · Next.js

Next.js App Router vs Pages Router: Interview Guide

How the App Router differs from the Pages Router in Next.js 15, how data fetching maps across, and how to migrate one route at a time.

~8 min readbeginnerupdated Oct 6, 2026

“What is the difference between the App Router and the Pages Router, and which would you use?” is often the first Next.js question in a frontend interview. A senior variant follows quickly: “Our app is on the Pages Router. How would you move it to the App Router without stopping feature work?”

Interviewers ask because most real Next.js codebases are somewhere in the middle of that move. They want to hear that the two routers are built on different React models, and that you can map APIs such as getServerSideProps onto the new ones without changing behaviour by accident.

Before you start

You should be comfortable with React components, props and hooks, and know what server-side rendering means: the server produces HTML, then React hydrates it in the browser. It helps to have seen a Pages Router page with getServerSideProps or getStaticProps. This guide targets Next.js 15 with React 19, where the App Router is the default for new projects and the Pages Router is still supported.

The short answer

The Pages Router (pages/) treats each file as a page component that is server-rendered and then fully hydrated, and it loads data through page-level functions such as getServerSideProps and getStaticProps. The App Router (app/) is built on React Server Components: components run on the server by default, can be async and fetch their own data, and only components marked 'use client' ship JavaScript. It adds nested layouts that persist across navigation, streaming with loading.tsx, error.tsx boundaries and Server Actions. Both routers can run in the same project, so migration can happen one route at a time.

How it works

In the Pages Router, a file in pages/ maps to a URL, and Next calls an exported data function before rendering the component. The function’s return value becomes props, and the same component then hydrates in the browser.

// pages/products/[id].tsx (Pages Router)
import type { GetServerSideProps } from 'next';

type Props = { product: { id: string; name: string; price: number } };

export const getServerSideProps: GetServerSideProps<Props> = async ({ params }) => {
  const res = await fetch(`https://api.example.com/products/${params!.id}`);
  if (res.status === 404) return { notFound: true };
  return { props: { product: await res.json() } };
};

export default function ProductPage({ product }: Props) {
  return <h1>{product.name}: {product.price}</h1>;
}
TSX

Everything in that page, including the parts that never change, is shipped as JavaScript and hydrated. Data loading is tied to the page: a header that needs the user must either receive props from the page or fetch on the client.

In the App Router, a folder maps to a URL segment and page.tsx makes it routable. The page itself is an async Server Component, so the data call moves into the component:

// app/products/[id]/page.tsx (App Router, Next.js 15)
import { notFound } from 'next/navigation';

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/products/${id}`, { cache: 'no-store' });
  if (res.status === 404) notFound();
  const product = await res.json();
  return <h1>{product.name}: {product.price}</h1>;
}
TSX

The rendered output of a Server Component reaches the browser as HTML plus a compact RSC payload, but its code does not. Layouts in parent folders wrap the page and stay mounted while users move between child routes.

Concern Pages Router App Router
Per-request data getServerSideProps async component plus a request API or cache: 'no-store'
Build-time data getStaticProps async component (static by default)
Dynamic paths getStaticPaths with fallback generateStaticParams with dynamicParams
ISR revalidate in getStaticProps export const revalidate, revalidatePath, revalidateTag
Shared shell _app.tsx, _document.tsx app/layout.tsx and nested layouts
Head tags next/head metadata and generateMetadata
Router hooks next/router next/navigation
API endpoints pages/api/*.ts app/**/route.ts

Step-by-step walkthrough

The safest migration path keeps pages/ running and moves one route at a time. A URL must be owned by only one router, so you move a page by deleting it from pages/ in the same change that adds it to app/.

Step 1: Create the root layout

The root layout replaces both _app.tsx and _document.tsx. It must render the html and body elements, and anything global, such as fonts or a theme provider, starts here.

// app/layout.tsx
import './globals.css';

export const metadata = { title: { template: '%s | Shop', default: 'Shop' } };

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

Providers that use React context, such as a theme or a query client, need 'use client'. Put them in their own file and wrap children with it, so the layout stays a Server Component.

Step 2: Move the data function into the component

Translate the data function by asking what it meant, not just what it did. getServerSideProps meant “run on every request”. In Next.js 15 an App Router page is prerendered at build time unless something tells Next it depends on the request. A fetch with cache: 'no-store', a call to cookies() or headers(), or await connection() from next/server does that. getStaticProps with revalidate: 60 becomes export const revalidate = 60 on the page.

Return values change too. { notFound: true } becomes a call to notFound(), and { redirect: ... } becomes redirect('/login') from next/navigation. Both work by throwing, so do not wrap them in a try/catch that swallows the error.

Step 3: Split out the interactive parts

A Pages Router component may use useState and click handlers anywhere. In app/, any file using hooks needs 'use client'. Rather than marking the whole page, extract the interactive piece, for example an “add to cart” button, into a small Client Component and keep the page on the server.

// app/products/[id]/AddToCart.tsx
'use client';
import { useState } from 'react';

export function AddToCart({ productId }: { productId: string }) {
  const [added, setAdded] = useState(false);
  return <button onClick={() => setAdded(true)}>{added ? 'Added' : 'Add to cart'}</button>;
}
TSX

Router hooks change at the same time. useRouter from next/router exposed router.query; in app/ you use useRouter, usePathname, useSearchParams and useParams from next/navigation. Importing the old hook inside app/ fails at runtime because the old router context is not mounted there.

Step 4: Replace head tags and decide on API routes

Move next/head tags into a metadata export or generateMetadata. API routes in pages/api keep working, so moving them is optional; a Route Handler uses Web Request and Response objects instead of Node-style req and res.

Worked scenario

A team migrates its product listing, pages/products/index.tsx, which used getServerSideProps to load current prices. The App Router version is an async page at app/products/page.tsx. In review, someone simplifies the fetch to await fetch(url) because “Next.js 15 does not cache fetch by default anyway”. Tests pass, the deploy goes out, and the next day support reports that prices on the listing have not changed since the release.

What happened: fetch without options is indeed not stored in the Data Cache, but nothing in the page depends on the request either. For a route with no dynamic segment and no request APIs, Next prerenders the page at build time, the fetch runs once, and its result is frozen into the HTML. getServerSideProps never behaved like that, so the migration silently changed the page from per-request to build-time rendering. The product detail page at app/products/[id] escaped the bug only because a dynamic segment without generateStaticParams is rendered on each request.

The build output already showed it. The /products route was marked ○ (Static) instead of ƒ (Dynamic). The fix is to state the intent:

// Per-request, as before
const res = await fetch('https://api.example.com/products', { cache: 'no-store' });

// Or: fresh enough every 60 seconds, served from cache in between
const res = await fetch('https://api.example.com/products', { next: { revalidate: 60 } });
TSX

The second option is usually better for a catalogue: users get cached pages and the API load drops, which getServerSideProps could not offer.

Common mistake

  • “The App Router is just a new folder.” The rendering model changed. Components are Server Components by default, and that affects where state, effects and secrets may live.
  • Marking every page 'use client'. It compiles, but it recreates the Pages Router bundle size without its data functions, and loses the reason to migrate.
  • Assuming fetch defaults define the route. Whether a route is static depends on everything it uses, not just the fetch cache option.
  • Moving the same URL into both folders. Next refuses conflicting routes. Delete the old page in the same commit.
  • Forgetting that layouts persist. A layout does not re-render on navigation, so code that ran on every page change in _app may need a Client Component using usePathname.

Verify the behavior

Run a production build and read the route table, then request the page twice and compare output.

npx next build
# Route (app)                Size  First Load JS
# ├ ○ /products            155 B         103 kB
# └ ƒ /products/[id]       155 B         103 kB
# ○  (Static)   prerendered as static content
# ƒ  (Dynamic)  server-rendered on demand

npx next start -p 3000 &
curl -s localhost:3000/products | grep -o 'price[^<]*'
curl -s localhost:3000/products | grep -o 'price[^<]*'   # identical when static
Terminal

When the page is meant to be dynamic, add export const dynamic = 'force-dynamic' temporarily and confirm the symbol becomes ƒ. When it must stay static, export const dynamic = 'error' makes the build fail if someone later adds a request API.

Follow-up questions

Can both routers share one project? Yes. Next routes each URL to whichever directory owns it, which is what makes route-by-route migration possible. Navigation between a pages/ route and an app/ route is a full page load, not a soft navigation.

Is the Pages Router deprecated? No. It is still supported in Next.js 15, but new features such as Server Actions and streaming layouts are App Router only.

How do _app providers map across? Context providers move into a Client Component that the root layout renders around children. Server Components inside still render on the server.

Interview exercise

You lead a 200-page Pages Router app. Product wants faster pages, and the team proposes a three-month freeze to rewrite everything in the App Router. What do you propose instead?

Answer and reasoning

Reject the freeze and migrate incrementally, because both routers coexist. First add app/layout.tsx with the shared providers, then move one low-risk, high-traffic route, such as the marketing home page, and measure bundle size and Core Web Vitals before and after. Next, migrate routes in order of benefit: content pages that can become static or ISR gain the most, while heavily interactive dashboards gain less. For each route, map the data function by intent (per-request, build-time or revalidated) and confirm the result in the next build table, because a silent change from dynamic to static is the main regression risk. Keep pages/api until there is a reason to move it. This keeps shipping features and spreads the risk across small, reversible releases.

Continue learning

Practise more routing questions in the Next.js interview questions and the Next.js MCQs. Read Server vs Client Components in Next.js for the component model behind the App Router, and React Server Components and the client boundary. The official App Router migration guide covers every API mapping.

More in Next.js

esc