{"id":474,"date":"2026-07-18T05:04:45","date_gmt":"2026-07-18T05:04:45","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/nextjs-app-router-the-pages-router-migration-i-finally-finished\/"},"modified":"2026-07-18T05:04:45","modified_gmt":"2026-07-18T05:04:45","slug":"nextjs-app-router-the-pages-router-migration-i-finally-finished","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/nextjs-app-router-the-pages-router-migration-i-finally-finished\/","title":{"rendered":"Next.js App Router: The Pages Router Migration I Finally Finished"},"content":{"rendered":"<p>Short version for the impatient: the Next.js App Router was worth the migration, but not for the reasons the launch posts sold me on. If you want the long version, and the two things that genuinely annoyed me along the way, keep reading.<\/p>\n<p>I put off moving a real production app off the Pages Router for about a year. Not because I doubted the App Router. It was more that &ldquo;rewrite your routing layer&rdquo; is the kind of sentence that suddenly makes fixing flaky tests sound fun. Then a client project forced my hand. They wanted streaming, per-section loading states, and a layout that didn&rsquo;t throw away the sidebar&rsquo;s state on every navigation. The Pages Router can fake some of that. It can&rsquo;t do it cleanly. So I blocked off a weekend and moved the whole thing.<\/p>\n<p>Here&rsquo;s what actually changed, with the before-and-after code, and where I&rsquo;d tell you to slow down.<\/p>\n<h2 id=\"the-mental-model-is-the-migration\">The mental model is the migration<\/h2>\n<p>The file moves are trivial. <code>pages\/about.tsx<\/code> becomes <code>app\/about\/page.tsx<\/code>. The part that takes a day to click is that most of your components now run on the server by default and never ship to the browser. That&rsquo;s the whole game. Everything in the <code>app\/<\/code> directory is a Server Component unless you opt out.<\/p>\n<p>Once that landed for me, the rest of the migration stopped feeling like a fight. I wasn&rsquo;t porting <code>getServerSideProps<\/code> into some new equivalent. I was deleting it, because the component itself can now do the fetch. The <a href=\"https:\/\/react.dev\/reference\/rsc\/server-components\" rel=\"nofollow noopener\" target=\"_blank\">React Server Components reference<\/a> is the clearest explanation of why this works, and it&rsquo;s worth twenty minutes before you touch any code.<\/p>\n<h2 id=\"data-fetching-deleting-getserversideprops\">Data fetching: deleting getServerSideProps<\/h2>\n<p>This is the change that sold me. Here&rsquo;s a typical Pages Router page that loads a user&rsquo;s dashboard:<\/p>\n<pre><code class=\"language-jsx\">\/\/ pages\/dashboard.tsx (Pages Router)\nexport async function getServerSideProps(context) {\n  const res = await fetch(`https:\/\/api.internal\/user\/${context.query.id}`);\n  const user = await res.json();\n  return { props: { user } };\n}\n\nexport default function Dashboard({ user }) {\n  return &lt;h1&gt;Welcome back, {user.name}&lt;\/h1&gt;;\n}\n<\/code><\/pre>\n<p>There&rsquo;s a whole serialization dance hiding in there. The data gets fetched on the server, JSON-stringified into the page&rsquo;s props, shipped to the client, and rehydrated. For a big dashboard that props payload gets heavy fast.<\/p>\n<p>The App Router version drops the ceremony:<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/[id]\/page.tsx (App Router)\nexport default async function Dashboard({ params }) {\n  const res = await fetch(`https:\/\/api.internal\/user\/${params.id}`);\n  const user = await res.json();\n  return &lt;h1&gt;Welcome back, {user.name}&lt;\/h1&gt;;\n}\n<\/code><\/pre>\n<p>The component is <code>async<\/code>. You <code>await<\/code> right in the render. The <code>user<\/code> object never gets serialized into a props blob for the client, because this component runs on the server and only its rendered output crosses the wire. On the dashboard I migrated, that alone cut the initial payload by a noticeable chunk, mostly because a bunch of data that used to ride along in props simply stopped shipping.<\/p>\n<p>If you&rsquo;re already reaching for a mutation after this, that&rsquo;s Server Actions territory, and I wrote up how I stopped hand-rolling API routes for those in <a href=\"https:\/\/abrarqasim.com\/blog\/nextjs-server-actions-when-i-stopped-writing-api-routes\" rel=\"noopener\">my post on Next.js Server Actions<\/a>.<\/p>\n<h2 id=\"layouts-stopped-being-a-hack\">Layouts stopped being a hack<\/h2>\n<p>The Pages Router never had a real answer for nested layouts. The community settled on a <code>getLayout<\/code> pattern bolted onto the page component, and it always felt like a workaround because it was one:<\/p>\n<pre><code class=\"language-jsx\">\/\/ pages\/_app.tsx (Pages Router)\nexport default function App({ Component, pageProps }) {\n  const getLayout = Component.getLayout || ((page) =&gt; page);\n  return getLayout(&lt;Component {...pageProps} \/&gt;);\n}\n\n\/\/ then every page had to export its own getLayout...\nDashboard.getLayout = (page) =&gt; &lt;SidebarLayout&gt;{page}&lt;\/SidebarLayout&gt;;\n<\/code><\/pre>\n<p>The App Router makes layouts a first-class file. A <code>layout.tsx<\/code> wraps every route in its folder, and it doesn&rsquo;t re-render when you navigate between child routes. That last part is the bit I actually cared about:<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/layout.tsx (App Router)\nexport default function DashboardLayout({ children }) {\n  return (\n    &lt;div className=&quot;flex&quot;&gt;\n      &lt;Sidebar \/&gt;\n      &lt;main&gt;{children}&lt;\/main&gt;\n    &lt;\/div&gt;\n  );\n}\n<\/code><\/pre>\n<p>Navigate from <code>\/dashboard\/reports<\/code> to <code>\/dashboard\/settings<\/code> and the <code>Sidebar<\/code> stays mounted. No flicker, no re-fetch, and my scroll position stays put. On the Pages Router I&rsquo;d been faking this with a global state hack that stashed the sidebar in a context provider, and it broke every few months whenever someone added a new top-level route and forgot to wire it up. Here it just works because the router understands the nesting. The official <a href=\"https:\/\/nextjs.org\/docs\/app\" rel=\"nofollow noopener\" target=\"_blank\">App Router docs<\/a> lay out the file conventions if you want the full set.<\/p>\n<h2 id=\"the-caching-that-bit-me\">The caching that bit me<\/h2>\n<p>Now the annoying part, because I promised honesty.<\/p>\n<p>The App Router caches aggressively by default, and the defaults changed more than once between releases. On my first deploy I had a page that showed stale data for fifteen minutes and I couldn&rsquo;t figure out why. Nothing in my code said &ldquo;cache this.&rdquo; The fetch did it for me.<\/p>\n<p>The fix was learning the opt-out knobs and being explicit everywhere data changes often:<\/p>\n<pre><code class=\"language-jsx\">\/\/ force a fresh fetch every request\nconst res = await fetch(url, { cache: 'no-store' });\n\n\/\/ or revalidate on a timer\nconst res = await fetch(url, { next: { revalidate: 60 } });\n<\/code><\/pre>\n<p>I don&rsquo;t love that the safe default for a lot of apps is &ldquo;turn the magic off until you understand it.&rdquo; If your app is mostly dynamic, you&rsquo;ll be sprinkling <code>cache: 'no-store'<\/code> around for a while before the model clicks. The rules also shifted between Next 14 and 15, so half the Stack Overflow answers you&rsquo;ll find describe behavior that no longer applies. Check the version on any advice before you trust it. Budget real time for this. The migration guide covers the caching behavior, and I&rsquo;d read the <a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/migrating\/app-router-migration\" rel=\"nofollow noopener\" target=\"_blank\">official Pages-to-App migration guide<\/a> end to end before you ship, not after you get paged about stale data.<\/p>\n<h2 id=\"where-use-client-actually-goes\">Where &lsquo;use client&rsquo; actually goes<\/h2>\n<p>The reflex when something breaks is to slap <code>'use client'<\/code> at the top of the file and move on. Resist it. Every time you do that, you&rsquo;re pulling that component and its imports back into the browser bundle, which is the exact thing you migrated to avoid.<\/p>\n<p>The pattern that kept my bundles small was pushing the client boundary as far down the tree as possible. A page stays a Server Component. Only the genuinely interactive leaf, the button with the click handler, the input with local state, gets marked:<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/like-button.tsx\n'use client';\nimport { useState } from 'react';\n\nexport default function LikeButton() {\n  const [liked, setLiked] = useState(false);\n  return &lt;button onClick={() =&gt; setLiked(!liked)}&gt;{liked ? 'Liked' : 'Like'}&lt;\/button&gt;;\n}\n<\/code><\/pre>\n<p>The server page imports <code>&lt;LikeButton \/&gt;<\/code> and renders it, but stays on the server itself. I got this wrong for the first two days and wondered why my bundle barely shrank. The boundary is a tool, not a default.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>Don&rsquo;t migrate the whole app in one shot unless something forces you to. The <code>app\/<\/code> and <code>pages\/<\/code> directories run side by side, which is the single best thing about this migration. Pick one leaf route with real data fetching, move it to a <code>page.tsx<\/code>, delete its <code>getServerSideProps<\/code>, and watch the network tab. Once you&rsquo;ve felt the payload drop and the layout stop flickering, you&rsquo;ll know whether the rest is worth your weekend.<\/p>\n<p>If you want to see the kind of production work I do with this stack, it&rsquo;s on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>. And if you only take one thing from this: learn the caching defaults before you deploy, not after.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I finally moved a production app from the Next.js Pages Router to the App Router. The real data-fetching and layout rewrite, plus what annoyed me.<\/p>\n","protected":false},"author":2,"featured_media":473,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I finally moved a production app from the Next.js Pages Router to the App Router. The real data-fetching and layout rewrite, plus what annoyed me.","rank_math_focus_keyword":"next.js app router","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[138,354],"tags":[171,38,541,61,540,41,423,389,63],"class_list":["post-474","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-frontend","category-react","tag-app-router-2","tag-frontend","tag-next-js-migration","tag-nextjs","tag-pages-router-2","tag-react","tag-react-server-components","tag-server-components-2","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/474","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=474"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/474\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/473"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=474"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=474"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=474"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}