App Router vs Pages Router in Next.js
Next.js has two routing systems: Pages Router (pages/ directory) and App Router (app/ directory). App Router was introduced in Next.js 13 and became the recommended approach starting from version 13.4.
Key Differences
| Aspect | Pages Router | App Router |
|---|---|---|
| Directory | pages/ | app/ |
| Default components | Client | Server (RSC) |
| Data fetching | getServerSideProps, getStaticProps | async components with fetch |
| Layouts | _app.tsx, _document.tsx | Nested layout.tsx |
| Loading UI | Manual implementation | loading.tsx out of the box |
| Errors | _error.tsx globally | error.tsx per route |
| Streaming | No | Yes, via Suspense |
| Server Actions | No | Yes |
Pages Router
In the Pages Router every file in the pages/ directory automatically becomes a route. Data is loaded through special functions:
// pages/problems/index.tsx
export async function getServerSideProps() {
const res = await fetch('https://api.hackfrontend.com/problems')
const problems = await res.json()
return { props: { problems } }
}
export default function ProblemsPage({ problems }) {
return (
<ul>
{problems.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Layouts are implemented through _app.tsx which wraps all pages:
// pages/_app.tsx
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
App Router
In the App Router components are server components by default. Data is loaded directly in the component:
// app/problems/page.tsx
import { db } from '@/lib/db'
export default async function ProblemsPage() {
const problems = await db.problem.findMany()
return (
<ul>
{problems.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
Layouts are nested and preserve state during navigation:
// app/problems/layout.tsx
export default function ProblemsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="problems-container">
<Sidebar />
<main>{children}</main>
</div>
)
}
What App Router Provides
Server Components by Default
Server component code does not end up in the client bundle. Heavy dependencies stay on the server:
// app/docs/[slug]/page.tsx
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getDocBySlug } from '@/lib/docs'
export default async function DocPage({
params
}: {
params: { slug: string }
}) {
const doc = await getDocBySlug(params.slug)
return <MDXRemote source={doc.content} />
}
The next-mdx-remote library is not sent to the client. This is exactly how articles are rendered on Hack Frontend.
Streaming
App Router supports Streaming through loading.tsx files and React Suspense:
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}
The user sees the skeleton immediately while data loads progressively.
Nested Layouts
Pages Router has a single global layout. In App Router layouts are nested and do not re-render when navigating between child pages:
When to Choose Which
App Router is suitable for new projects. It is the recommended approach and receives all new features.
Pages Router continues to be supported. If a project is already built on Pages Router and works well, migration is not required.
Interview tip:
Both routers can coexist in the same project. This allows gradual migration, one route at a time.