
Mykola Nesterchuk - March 11, 2026
Next.js now ships with two completely different ways to build your app. The Pages Router has been around since the beginning - stable, predictable, well-documented. The App Router arrived in Next.js 13 and changed almost everything: how you fetch data, how layouts work, what runs on the server, and how caching behaves.
If you're starting a new project, you need to pick one. If you're maintaining an existing codebase, you'll eventually be asked whether to migrate. This guide gives you a practical picture of both - not a surface-level comparison, but the real differences that affect how you architect and debug your app.
Both routers are file-system based, but the conventions are different.
In the Pages Router, every file inside pages/ becomes a route. The filename maps directly to the URL:
pages/
index.tsx → /
about.tsx → /about
blog/[slug].tsx → /blog/:slug
api/users.ts → /api/users
In the App Router, routes are defined by folders inside app/. The actual page lives in a page.tsx file inside that folder:
app/
page.tsx → /
about/
page.tsx → /about
blog/
[slug]/
page.tsx → /blog/:slug
api/
users/
route.ts → /api/users
The folder-based approach feels more verbose at first but unlocks co-location - you can put loading.tsx, error.tsx, and layout.tsx next to each page.tsx and they apply automatically to that route segment.
This is where the two routers diverge most significantly.
In the Pages Router, data fetching happens through exported async functions that Next.js calls at request or build time:
// Pages Router export async function getServerSideProps(context) { const user = await fetchUser(context.params.id); return { props: { user } }; } export async function getStaticProps() { const posts = await fetchPosts(); return { props: { posts }, revalidate: 60 }; } export default function Page({ user }) { return <Profile user={user} />; }
The rules are clear: getServerSideProps runs on every request (SSR), getStaticProps runs at build time (SSG), and getStaticPaths defines which dynamic routes to pre-render. These functions only run in page-level files - you can't use them inside a component.
In the App Router, you fetch data directly inside the component. Server Components are async by default:
// App Router export default async function Page({ params }) { const user = await fetchUser(params.id); return <Profile user={user} />; }
No boilerplate, no prop drilling from the page down. Any Server Component anywhere in the tree can fetch its own data:
// This component fetches its own data - no props needed from the parent async function RecentPosts() { const posts = await fetchRecentPosts(); return ( <ul> {posts.map(post => <li key={post.id}>{post.title}</li>)} </ul> ); }
For SSG-equivalent behavior, use fetch with default caching. For SSR, add cache: 'no-store'. For ISR, use next: { revalidate: 60 }:
// Static (build time) const data = await fetch('https://api.example.com/posts'); // Dynamic (every request) const data = await fetch('https://api.example.com/user', { cache: 'no-store' }); // ISR (revalidate every 60 seconds) const data = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } });
The App Router introduces a split that doesn't exist in the Pages Router. By default, every component in app/ is a Server Component - it runs on the server, never ships its code to the browser, and can be async.
When you need interactivity - state, event handlers, browser APIs - you add 'use client' at the top of the file:
'use client'; import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }
The key mental model: 'use client' marks a boundary, not a component type. Everything below that boundary in the component tree also becomes a Client Component. This means you want to push 'use client' as deep as possible - keep the fetching and rendering on the server, only hydrate the interactive parts.
// Server Component - fetches data, renders the static parts export default async function PostPage({ params }) { const post = await fetchPost(params.slug); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> {/* Only this small component needs to be a Client Component */} <LikeButton postId={post.id} initialLikes={post.likes} /> </article> ); }
A common mistake is putting 'use client' at the top of a layout or page file and unintentionally sending everything to the client.
Layouts are one of the strongest arguments for the App Router.
In the Pages Router, shared layouts require a manual wrapper pattern in _app.tsx. Nested layouts - different layout per section - need extra boilerplate and are error-prone:
// Pages Router - manual layout wrapping in _app.tsx export default function App({ Component, pageProps }) { const Layout = Component.layout || DefaultLayout; return <Layout><Component {...pageProps} /></Layout>; }
In the App Router, layouts are a first-class feature. A layout.tsx file wraps all routes in that folder and persists across navigation - it doesn't re-render when the user navigates between child routes:
// app/dashboard/layout.tsx // This layout renders once and persists across /dashboard/*, /dashboard/settings, etc. export default function DashboardLayout({ children }) { return ( <div className="dashboard"> <Sidebar /> <main>{children}</main> </div> ); }
Nested layouts compose naturally:
app/
layout.tsx ← root layout (html, body, global nav)
dashboard/
layout.tsx ← dashboard layout (sidebar)
page.tsx
settings/
layout.tsx ← settings layout (tabs)
page.tsx
Each level only re-renders when its own segment changes. This is a significant UX improvement for apps with complex navigation.
The App Router has four separate caching layers, and this is where many developers run into unexpected behavior.
fetch() calls within a single render passcache/revalidate optionsThe upside: when configured correctly, caching is extremely efficient. The downside: debugging why data isn't updating requires understanding which cache layer is holding stale data, and the mental overhead is real.
The Pages Router is simpler here. getServerSideProps always runs on request with no caching. getStaticProps with revalidate handles ISR. There are no surprises.
Honest caveat: Some developers report higher TTFB with the App Router compared to Pages Router for SSR-heavy pages. The overhead of RSC serialization is real - profile your specific app before assuming App Router is always faster.
In the App Router, if you await multiple fetches sequentially in a component, you create a waterfall:
// ❌ Sequential - each waits for the previous const user = await fetchUser(id); const posts = await fetchUserPosts(id);
Fetch them in parallel instead:
// ✅ Parallel - both fire at the same time const [user, posts] = await Promise.all([ fetchUser(id), fetchUserPosts(id), ]);
Or split them into separate components and wrap each with <Suspense> - that way each part of the UI renders as soon as its own data arrives:
export default function Page({ params }) { return ( <> <Suspense fallback={<UserSkeleton />}> <UserProfile id={params.id} /> </Suspense> <Suspense fallback={<PostsSkeleton />}> <UserPosts id={params.id} /> </Suspense> </> ); }
This streaming pattern is one of the App Router's genuine performance wins - users see content progressively instead of waiting for the slowest fetch.
| Situation | Recommendation |
|---|---|
| New project | App Router |
| Existing Pages Router project | Keep Pages Router, migrate incrementally |
| Complex nested layouts | App Router |
| Simple marketing site or blog | Either - Pages Router is simpler |
| Server-heavy data fetching | App Router (Server Components) |
| Heavy client interactivity | Either - both support client-side rendering |
| Team unfamiliar with RSC | Pages Router until the team is ready |
| Need stable, battle-tested patterns | Pages Router |
Next.js supports both routers in the same project. You can migrate incrementally by keeping pages/ working while adding new routes to app/:
app/ directory alongside your existing pages/app/layout.tsx (required)getServerSideProps with async Server ComponentsgetStaticProps with default cached fetch calls'use client'The biggest conceptual shift isn't the syntax - it's thinking in terms of the Server/Client boundary rather than page-level data fetching functions.
Both routers are production-ready and supported. Here's the practical takeaway:
'use client' as deep as possible.