Skip to content

Next.js App Router vs Pages Router: what actually changes

Next.js App Router vs Pages Router: what actually changes

I migrated a medium-sized Next.js app from Pages Router to App Router about four months ago. I had been putting it off because every article I read said something different. Some said the App Router was the future and you should migrate now. Others said it was half-baked and to wait. Both camps were kind of right.

Now that I’ve been living with it for a while: the App Router is better. But it’s a genuinely different mental model, not just an upgrade path, and there are specific things you’ll get wrong the first time.

This is what I wish someone had told me before I started.

The actual difference — it’s not the file structure

Most explanations of nextjs app router vs Pages Router lead with the file system changes: move from /pages/ to /app/, use page.tsx files, add layout.tsx for shared layouts. That stuff matters, but it’s not the conceptual shift.

The real change is that App Router is built around React Server Components. By default, every component in /app/ is a Server Component — it renders on the server, sends HTML to the client, and ships no JavaScript for that component to the browser.

If you want client-side interactivity, you opt in explicitly with 'use client' at the top of the file.

Pages Router is the opposite: every page is a client component by default. You use getServerSideProps or getStaticProps to pull in server-side data, but the component itself runs in the browser.

Here’s how that looks in practice:

// pages/dashboard.tsx — Pages Router (client component by default)
import { useState } from 'react'

export default function Dashboard({ initialData }) {
  const [data, setData] = useState(initialData)
  return <div>{data.title}</div>
}

export async function getServerSideProps() {
  const data = await fetchDashboardData()
  return { props: { initialData: data } }
}
// app/dashboard/page.tsx — App Router (server component by default)
async function Dashboard() {
  const data = await fetchDashboardData() // direct async call, no special API
  return <div>{data.title}</div>
}

The App Router version is shorter. But if you need state or user interaction, you split the component:

// app/dashboard/page.tsx
import DashboardClient from './DashboardClient'

async function Dashboard() {
  const data = await fetchDashboardData()
  return <DashboardClient initialData={data} />
}

// app/dashboard/DashboardClient.tsx
'use client'
import { useState } from 'react'

export default function DashboardClient({ initialData }) {
  const [data, setData] = useState(initialData)
  return <div>{data.title}</div>
}

That split is the thing you’ll fight with for the first few weeks.

Data fetching: where it got confusing for me

In Pages Router, data fetching is explicit and centralized. getServerSideProps for dynamic data, getStaticProps for build-time data. One function, one place per page.

In App Router, any Server Component can just await a database call, an API call, whatever. No special API needed — it’s just async functions.

This is genuinely nice once you’re used to it. You stop thinking about “where do I put the data fetching” and just fetch where you need it. The downside is that data fetching can get scattered across the component tree, which takes discipline to keep organized.

The caching model also changed significantly. Pages Router has a relatively simple story: SSR means fresh data every request, SSG means build-time data. App Router has a granular caching system where you control caching at the individual fetch level:

// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
})

// No caching — always fresh
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

More powerful than Pages Router. Also more footguns. The Next.js caching documentation is worth reading before you ship anything to production — the default behavior isn’t always what you’d expect.

The mistake everyone makes first

Based on my own migration and talking to others who’ve done it: the most common first mistake is putting too much in Client Components.

The pattern goes like this: you move a page to App Router, something breaks, you add 'use client' at the top of the file, it works. You repeat this until everything works. By the end, your app is functionally equivalent to Pages Router — you’ve just moved files around.

That’s not fatal, but you’ve missed the performance benefits and the architectural point.

The right approach is pushing 'use client' as far down the component tree as possible. Keep it on the interactive leaves — buttons, forms, inputs — and let everything above them stay as Server Components. This takes restructuring. The payoff is real: less JavaScript shipped to the client, faster initial loads, and a cleaner separation between data fetching and UI state.

The second thing that catches people is passing non-serializable values from Server Components to Client Components. You can’t pass a function or a class instance as a prop across that boundary. Strings, numbers, plain objects — fine. Functions and complex objects — you’ll get an error. Restructuring around this is annoying but usually points you toward a cleaner design anyway.

If you’re migrating and want to understand how this affects form handling and mutations, I covered how Server Actions changed the way I think about API routes in a separate post — why I stopped writing API routes after Server Actions. The two changes are related and fit together once you see them together.

Should you migrate an existing project?

If your project is actively developed and you’re planning to stay on Next.js long-term: yes. The App Router is where Next.js is going. Pages Router is in maintenance mode — it gets security fixes, not new features.

The migration itself isn’t that painful, but it isn’t automatic either. The Next.js team has an incremental migration guide that lets you run both routers simultaneously — /pages and /app coexist in the same project. I migrated one route at a time over about two weeks.

The parts that take the longest aren’t the routing changes — they’re the data fetching restructuring and the client/server splits. Budget more time for those than you think you need.

If your project is small and not actively changing: probably not worth the disruption. Pages Router isn’t going away; it’s just not getting new features.

The mental model click

App Router makes sense all at once, not gradually. The first two weeks feel like working against the framework. Then something shifts and it starts feeling natural.

The click usually happens when you stop treating “where do I fetch this data” and “is this component client or server” as two separate decisions and start thinking about them together. Server by default. Client when you need interactivity. Fetch at the component that needs the data, not at a centralized function at the top of the page.

It’s a better model. Getting there takes some patience.

I write about this kind of stack transition stuff at abrarqasim.com — come by if you’re navigating similar decisions.