App Router vs Pages Router in Next.js
App Router vs Pages Router in Next.js - two routing systems in the same framework that disagree about one thing: where rendering happens. Pages Router assumes the browser does the work and calls server helpers when needed. App Router assumes the server does the work and lets the browser opt in.
Theory
TL;DR
- Analogy: Pages Router is a restaurant where you order at the table and wait for the kitchen. App Router is a buffet where plates arrive pre-assembled from the server.
- Pages Router ships a full JS bundle to the browser for hydration. App Router streams server-rendered HTML and only hydrates parts marked
'use client'. - The entire API difference in one line:
getServerSidePropsvsasynccomponent. - New project: App Router. Existing project on Pages: migrate gradually, one route at a time. Both coexist in the same repo.
- Streaming, nested layouts, Server Actions, and partial prerendering all live on the App Router side only.
Quick example
Pages Router fetches data outside the component and passes it down as props:
// pages/posts/[id].tsx
export async function getServerSideProps({ params }) {
const post = await fetch(`https://api.itlead.org/posts/${params.id}`).then(r => r.json())
return { props: { post } } // serialized to JSON, sent to client
}
export default function PostPage({ post }) {
return <h1>{post.title}</h1> // runs in browser after full hydration
}App Router merges data fetching into the component:
// app/posts/[id]/page.tsx
export default async function PostPage({ params }) {
const post = await fetch(`https://api.itlead.org/posts/${params.id}`).then(r => r.json())
return <h1>{post.title}</h1> // streamed as HTML, no full bundle hydration
}The App Router version has no exported helper and no props interface. The fetch call runs on the server and never appears in the client bundle.
Key difference
Pages Router treats data fetching as a file-level export that runs before the component renders. The component itself is always a client component and ships to the browser. App Router makes async server components the default: they fetch their own data, run on the server, and send HTML. The browser only hydrates parts explicitly marked 'use client'. Everything else stays as static markup.
When to use
- New project with SEO requirements: App Router. Server rendering plus streaming improves LCP on dynamic content.
- Legacy codebase pre-Next.js 13: keep Pages Router and migrate new routes to
app/gradually. - Static export (
output: 'export'): Pages Router only. React Server Components are not compatible with static export mode. - Team new to React Server Components: Pages Router is easier to reason about while the team gets familiar with the RSC model.
Side-by-side table
| Feature | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| Introduced | Next.js 9 (2019), default until 13 | Next.js 13 (2023), stable 13.4+, default in 14+ |
| Default component | Client component | React Server Component |
| Data fetching | getStaticProps, getServerSideProps | async components with fetch |
| Layouts | Global _app.tsx | Nested layout.tsx at any depth |
| Loading UI | Manual state management | loading.tsx with Suspense built in |
| Error handling | Global _error.tsx | Per-route error.tsx |
| Streaming | Not supported | Yes, via <Suspense> and loading.tsx |
| Metadata | <Head> component | generateMetadata per route |
| Mutations | API route plus client fetch | Server Actions with 'use server' |
| Bundle size | Larger (full hydration) | Smaller (server components stay on server) |
| Caching | Manual | Automatic via fetch options |
| When to use | Legacy apps, static exports | New apps, dynamic UIs, colocation |
How a request flows through each router
For Pages Router on an SSR request: run getServerSideProps on the server, serialize the return value to JSON, send props to the client, hydrate the full component tree with React's hydrateRoot. Every page ships its source to the browser even if the content never changes after load.
App Router works differently. Next.js scans the app/ tree at build time and generates a route manifest. On a request:
- Next.js walks the folder from root, collecting every
layout.tsxalong the path to the matched route. - Every component on that path runs on the server. Async components pause at
awaitwhile the runtime streams ready HTML to the browser incrementally. - Components marked
'use client'are serialized with their props as an RSC Payload (via React Flight over HTTP) and sent to the browser. - The browser hydrates only those client components. Server components stay as pure HTML and never touch the React runtime in the browser.
One practical consequence: fetch inside App Router components is patched by Next.js. Identical fetch calls across multiple components in the same request are deduplicated automatically. If two components hit the same URL, only one network request goes out. Pages Router has no equivalent for this.
Data fetching as a component concern
Pages Router forces data fetching to the file level. You write getServerSideProps or getStaticProps as named exports, return { props }, and the page receives those props. It worked, but sharing fetch logic between a page and a deeply nested component was awkward. All data had to flow down from the top.
App Router lets any component be async. A layout can fetch. A page can fetch. A deeply nested server component can fetch independently. Each await is processed in parallel where possible, and Next.js can start streaming the parts that are ready while the slower parts keep loading. Data lives next to the component that uses it, not hoisted to the top of the file tree.
One thing to watch: fetch responses are cached by default in App Router. Use { next: { revalidate: 3600 } } for time-based revalidation or { cache: 'no-store' } when you always need fresh data.
Common mistakes
Marking the whole page with 'use client'
'use client' // wrong: entire page ships as client bundle
export default function Page() {
const [open, setOpen] = useState(false)
return <div>...</div>
}This cancels the RSC benefit. The whole file ships to the browser, bundle size roughly doubles, and TTFB increases because the server cannot stream HTML before the client JS executes. Extract the stateful part into a small child component and mark only that file 'use client'. The page itself stays a server component.
Using useState or useEffect in an App Router page
// wrong: hooks are not available in React Server Components
export default function Page() {
const [data, setData] = useState(null)
useEffect(() => { fetchData().then(setData) }, []) // build error
}Server components are async functions, not React components in the traditional sense. useState, useEffect, and useContext all throw a build error inside app/. Use async/await instead:
export default async function Page() {
const data = await fetchData() // runs on server, no hook needed
return <div>{data.title}</div>
}Expecting _app.tsx providers to work inside app/
In hybrid mode, pages/_app.tsx and app/layout.tsx run in separate contexts. A React Context provider added to _app.tsx is not visible inside any app/ route. Auth providers and theme providers must be either duplicated or moved entirely to app/layout.tsx once migration is complete. This trips up almost every team on their first hybrid project.
Missing fallback in Pages Router dynamic routes
export async function getStaticPaths() {
return { paths: [...], fallback: false } // 404 for paths not pre-built at build time
}fallback: false works for content that never grows after deployment. For anything that adds new paths over time, use fallback: 'blocking' to enable on-demand static generation without the flash of a loading state.
Real-world usage
- Vercel's own dashboard: App Router with nested layouts sharing navigation and streaming for live metrics.
- Linear: App Router parallel routes for issue modals that render over the list without a full page reload.
- Supabase dashboard: App Router server
fetchfor realtime database queries colocated with the components that display them. - T3 Stack (
create-t3-app): App Router by default since v10, paired with tRPC and RSC. - Stripe Dashboard: hybrid mode, actively migrating to App Router for the payments UI that benefits from server component rendering.
Follow-up questions
Q: Why can't you use getServerSideProps inside app/?
A: It is a Pages Router convention that Next.js only reads inside pages/. The App Router equivalent is an async component that fetches its own data directly. The two conventions are intentionally separate, not interchangeable.
Q: If I mark a component 'use client', can it still receive server-fetched data?
A: Yes. A server component can render a 'use client' child and pass server-fetched data as props. Strings, numbers, plain objects, and arrays serialize cleanly across the boundary. Functions do not, so you cannot pass a fetch function as a prop. You wrap it in a Server Action with 'use server' instead.
Q: How does Next.js deduplicate fetch requests in App Router?
A: fetch is patched in the App Router context. Calls with the same URL and options within the same render pass are deduplicated automatically. Only one network request actually goes out, regardless of how many components call it.
Q: Can Pages Router and App Router coexist in one project permanently?
A: Technically yes, Next.js supports it. In practice, sharing auth providers, session state, and layout logic across both gets complicated quickly. Most teams treat hybrid mode as a migration phase rather than a permanent architecture.
Q: What breaks when you deploy App Router to the Edge runtime?
A: No Node.js APIs. fs, crypto, and anything that depends on Node internals will throw at runtime on the Edge. Libraries like Prisma need adapter packages such as @prisma/adapter-edge to work. Use Web API equivalents or restrict Edge deployment to routes that do not depend on Node-specific code.
Examples
Posts list in both routers
// pages/posts/index.tsx (Pages Router)
import type { GetServerSideProps } from 'next'
type Post = { id: string; title: string }
export const getServerSideProps: GetServerSideProps = async () => {
const res = await fetch('https://api.itlead.org/posts')
const posts: Post[] = await res.json()
return { props: { posts } }
}
export default function PostsPage({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}// app/posts/page.tsx (App Router)
import { db } from '@/lib/db'
export default async function PostsPage() {
const posts = await db.post.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}The App Router version has no exported helper, no separate data function, and no props interface. The database import lives in the page file and stays on the server. That last part is what changes your bundle size in production. In Pages Router you would have to keep the db import inside getServerSideProps and pass data through props to avoid leaking the driver to the browser.
Nested layout with streaming
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}// app/dashboard/loading.tsx
import { DashboardSkeleton } from '@/components/dashboard-skeleton'
export default function Loading() {
return <DashboardSkeleton />
}// app/dashboard/page.tsx
import { getRecentActivity } from '@/lib/activity'
export default async function DashboardPage() {
const activity = await getRecentActivity()
return (
<section>
<h1>Recent activity</h1>
<ul>
{activity.map((item) => (
<li key={item.id}>{item.description}</li>
))}
</ul>
</section>
)
}On navigation to /dashboard, Next.js renders the layout and sidebar immediately, then shows DashboardSkeleton while getRecentActivity() is pending. When data arrives, the skeleton swaps for real content. The sidebar never re-renders because layouts preserve state across navigation within their subtree. In Pages Router you wire all of this yourself with useState, a spinner, and a useEffect fetch on mount. These three files replace all of that, and each one is cached independently.
Server and client components in one page
Common interview scenario. What needs 'use client' here, and what does not?
// app/search/page.tsx
import { searchPosts } from '@/lib/search'
import { SearchInput } from './search-input'
export default async function SearchPage({
searchParams,
}: {
searchParams: { q?: string }
}) {
const query = searchParams.q ?? ''
const results = query ? await searchPosts(query) : []
return (
<div>
<SearchInput initialQuery={query} />
<ul>
{results.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}// app/search/search-input.tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function SearchInput({ initialQuery }: { initialQuery: string }) {
const [value, setValue] = useState(initialQuery)
const router = useRouter()
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') router.push(`/search?q=${value}`)
}}
placeholder="Search..."
/>
)
}The page is a server component: reads searchParams, calls searchPosts on the server, renders results. SearchInput needs 'use client' because it uses useState and useRouter. The page passes initialQuery as a prop, and that is fine because strings serialize cleanly across the boundary.
The part many teams get wrong on first migration: you cannot pass searchPosts itself as a prop to the client component. Functions do not serialize. If the client needed to trigger search directly, you would wrap searchPosts in a Server Action with 'use server' and pass the action as the prop. That is the split most teams misread their first time through.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.