Blog/Behind the Code/Migrating from Pages to App Router
POST
February 15, 2026
LAST UPDATEDFebruary 15, 2026

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.

Tags

Next.jsMigrationCase Study
Migrating from Pages to App Router
8 min read

Migrating from Pages to App Router

TL;DR

Migrating a 100,000-line Next.js app from Pages Router to App Router eliminated over 40% of client-side bundle size by embracing React Server Components, despite requiring a complete paradigm shift in data fetching and state management. The migration took three months and fundamentally changed how the team thinks about server vs. client boundaries.

The Challenge

The application was an enterprise dashboard built over two years using Next.js Pages Router. It served internal teams managing logistics operations: inventory tracking, shipment scheduling, real-time fleet monitoring, and reporting. The codebase had grown to roughly 100,000 lines of TypeScript across 300+ pages and components.

The problems were compounding:

  • Bundle size was out of control. The initial page load shipped over 400KB of JavaScript (gzipped). Every page pulled in heavy charting libraries, date utilities, and state management code regardless of whether the current view needed them.
  • Data fetching was scattered. getServerSideProps functions had grown into sprawling data orchestrators, sometimes fetching from 5-6 different API endpoints. The serialization boundary between server and client meant we were shipping raw API responses to the client and transforming them in React state.
  • Layouts were duplicated. The Pages Router has no native layout system. The team had built a custom layout wrapper using _app.tsx and per-page layout functions, but nested layouts required complex composition patterns that new developers constantly got wrong.
  • Performance had plateaued. Traditional optimization techniques (code splitting, dynamic imports, tree shaking) had been applied aggressively. Further improvements required an architectural change.

The App Router, with React Server Components (RSC), offered a path forward. Server Components run on the server and send rendered HTML to the client without any JavaScript bundle. For a data-heavy dashboard where most components simply display fetched data, this was transformative.

The Architecture

Migration Strategy: The Strangler Fig Pattern

We did not attempt a big-bang rewrite. Instead, we used the strangler fig pattern, migrating route by route while both routers coexisted. Next.js supports this natively: the app/ directory (App Router) and pages/ directory (Pages Router) can live side by side in the same project.

The migration order was deliberate:

  1. Static pages first. Settings, help documentation, about pages. These had no complex data fetching and served as a low-risk way to validate the deployment pipeline.
  2. Read-heavy pages next. Reporting dashboards, inventory views, shipment tracking. These benefited most from Server Components because they fetch data and render it without interactivity.
  3. Interactive pages last. Real-time fleet monitoring, drag-and-drop scheduling, multi-step forms. These required careful thought about where to draw the client/server boundary.

Data Fetching: Goodbye getServerSideProps

The most significant paradigm shift was data fetching. In Pages Router, data flows through a single gateway:

typescript
// Old pattern: pages/dashboard.tsx
export async function getServerSideProps(context: GetServerSidePropsContext) {
  const [inventory, shipments, alerts] = await Promise.all([
    fetchInventory(context.params.warehouseId),
    fetchShipments(context.params.warehouseId),
    fetchAlerts(context.params.warehouseId),
  ]);
 
  return {
    props: { inventory, shipments, alerts }, // Serialized and sent to client
  };
}
 
export default function Dashboard({ inventory, shipments, alerts }: Props) {
  // All data available as props, but ALL of this component's code ships to the client
  return (
    <DashboardLayout>
      <InventoryTable data={inventory} />
      <ShipmentList data={shipments} />
      <AlertsFeed data={alerts} />
    </DashboardLayout>
  );
}

In App Router, each component fetches its own data directly on the server:

typescript
// New pattern: app/dashboard/page.tsx (Server Component by default)
import { InventoryTable } from './inventory-table';
import { ShipmentList } from './shipment-list';
import { AlertsFeed } from './alerts-feed';
 
export default function DashboardPage({
  params,
}: {
  params: { warehouseId: string };
}) {
  return (
    <div className="grid grid-cols-12 gap-6">
      <InventoryTable warehouseId={params.warehouseId} />
      <ShipmentList warehouseId={params.warehouseId} />
      <AlertsFeed warehouseId={params.warehouseId} />
    </div>
  );
}
typescript
// app/dashboard/inventory-table.tsx (Server Component)
async function InventoryTable({ warehouseId }: { warehouseId: string }) {
  const inventory = await fetchInventory(warehouseId);
 
  return (
    <table>
      <thead>
        <tr>
          <th>SKU</th>
          <th>Product</th>
          <th>Quantity</th>
          <th>Location</th>
        </tr>
      </thead>
      <tbody>
        {inventory.map((item) => (
          <tr key={item.sku}>
            <td>{item.sku}</td>
            <td>{item.name}</td>
            <td>{item.quantity}</td>
            <td>{item.location}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

The key insight: InventoryTable is a Server Component. It fetches data, renders HTML, and sends zero JavaScript to the client. The fetchInventory function, its dependencies, and the rendering logic are entirely server-side. This is where the bundle size reduction came from.

The Client/Server Boundary

Drawing the line between server and client components was the hardest part of the migration. The rule I established:

A component is a Client Component only if it uses browser APIs, React hooks (useState, useEffect, useRef), or event handlers (onClick, onChange). Everything else stays on the server.

In practice, this meant most components remained Server Components. Interactive elements were pushed to the leaves of the component tree:

typescript
// Server Component - fetches data, renders structure
async function ShipmentList({ warehouseId }: { warehouseId: string }) {
  const shipments = await fetchShipments(warehouseId);
 
  return (
    <div>
      <h2>Active Shipments</h2>
      {shipments.map((s) => (
        <ShipmentCard key={s.id} shipment={s} />
      ))}
    </div>
  );
}
 
// This is still a Server Component - no interactivity needed
function ShipmentCard({ shipment }: { shipment: Shipment }) {
  return (
    <div className="rounded-lg border p-4">
      <p className="font-bold">{shipment.trackingNumber}</p>
      <p>{shipment.destination}</p>
      <ShipmentActions shipmentId={shipment.id} /> {/* Client Component */}
    </div>
  );
}
tsx
// Client Component - needs onClick handlers
'use client';
 
import { useState } from 'react';
 
function ShipmentActions({ shipmentId }: { shipmentId: string }) {
  const [isUpdating, setIsUpdating] = useState(false);
 
  async function handleMarkDelivered() {
    setIsUpdating(true);
    await fetch(`/api/shipments/${shipmentId}/deliver`, { method: 'POST' });
    setIsUpdating(false);
  }
 
  return (
    <button onClick={handleMarkDelivered} disabled={isUpdating}>
      {isUpdating ? 'Updating...' : 'Mark Delivered'}
    </button>
  );
}

Layouts: Finally Done Right

The App Router's nested layout system solved one of our biggest architectural pain points. The dashboard had a consistent sidebar, top navigation, and breadcrumb system that previously required a fragile custom layout composition:

typescript
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar';
import { TopNav } from '@/components/top-nav';
 
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <div className="flex flex-1 flex-col">
        <TopNav />
        <main className="flex-1 overflow-auto p-6">{children}</main>
      </div>
    </div>
  );
}

Nested layouts preserve state across navigations. When a user navigates between dashboard sub-pages, the sidebar does not re-render and the scroll position is maintained. This was a significant UX improvement that came for free with the architecture.

Handling the Gotchas

Context providers. Global context providers (theme, auth, toast notifications) must be Client Components. I created a single Providers wrapper component marked with 'use client' and placed it in the root layout. This kept the provider boilerplate contained.

Third-party libraries. Many npm packages do not export Server Component-compatible modules. Libraries that use window, document, or React hooks internally must be wrapped in Client Components. I maintained a client-wrappers/ directory for these cases.

Search params and dynamic behavior. Server Components cannot access useSearchParams(). For pages that needed URL search parameters, I used the searchParams prop available on page components, or pushed the search param reading to a Client Component at the leaf level.

Key Decisions & Trade-offs

Incremental migration over big-bang rewrite. This was non-negotiable. A three-month feature freeze for a rewrite was not acceptable to the business. The incremental approach let us ship improvements continuously while migrating. The trade-off was maintaining two routing paradigms simultaneously, which confused some team members.

Aggressive Server Component adoption. I pushed to keep as much as possible on the server. The trade-off is that Server Components require a mental model shift. Developers accustomed to useEffect-driven data fetching needed to unlearn patterns. I ran two internal workshops to build team fluency.

fetch() with revalidation over client-side data fetching libraries. In Pages Router, we used React Query extensively. In App Router, I replaced most of those patterns with native fetch() calls in Server Components with next.revalidate options. React Query was retained only for interactive, real-time features that needed client-side cache invalidation.

Not migrating everything. A few deeply interactive pages (the drag-and-drop schedule board, the real-time fleet map) were left as Pages Router routes even at launch. The cost of migrating them was high and the Server Component benefit was minimal since they were almost entirely interactive.

Results & Outcomes

The migration delivered significant improvements across the board:

  • Bundle size dropped dramatically. Client-side JavaScript went from ~400KB to ~230KB gzipped. The heaviest charting and data transformation libraries were no longer shipped to the browser.
  • Time to Interactive improved substantially. Pages that previously took several seconds to become interactive now rendered almost instantly because Server Components send pre-rendered HTML.
  • Layout stability improved. The nested layout system eliminated the flash of unstyled content that occurred during route transitions under the old custom layout system.
  • Developer experience improved. New pages were faster to build because data fetching was co-located with the component that needed it, rather than hoisted to a centralized getServerSideProps function.

The team's reaction was initially mixed (the paradigm shift was uncomfortable) but turned strongly positive once they experienced the simplicity of writing Server Components that just fetch data and render.

What I'd Do Differently

Create a decision tree document before starting. The most frequent question during migration was "should this be a Client Component or Server Component?" I eventually wrote a decision tree, but having it from day one would have prevented several unnecessary refactors.

Migrate the data layer first. I wish I had abstracted the data fetching layer (API client, error handling, caching) into a shared module before starting the route migration. This would have avoided duplicating fetch logic between old getServerSideProps patterns and new Server Component patterns.

Set up bundle size monitoring in CI. We tracked bundle size manually at milestones, but automated bundle size checks on every PR would have caught regressions earlier. A few PRs accidentally added 'use client' to components that did not need it, inflating the bundle until someone noticed.

Invest in error boundary strategy upfront. Server Component errors behave differently from Client Component errors. I should have established a consistent error boundary pattern (using error.tsx files) from the start rather than retrofitting them after encountering production errors.

FAQ

Is it worth migrating from Pages Router to App Router?

Yes, if performance is a priority. React Server Components can dramatically reduce client-side bundle size and improve load times, though the migration requires rethinking data fetching and state management patterns. The biggest wins come from data-heavy applications where most of the UI is displaying fetched data rather than handling user interactions. If your application is highly interactive (think Figma or a spreadsheet editor), the benefits of Server Components are less pronounced because most components will need to be Client Components anyway. For enterprise dashboards, content sites, and e-commerce platforms, the migration is almost always worthwhile.

What are the biggest challenges when migrating to App Router?

The main challenges include adapting to the new data fetching paradigm (no more getServerSideProps), handling client vs server component boundaries, and refactoring state management that relied on client-side patterns. The mental model shift is the hardest part. Developers need to stop thinking in terms of "fetch data at the page level, pass it down as props" and start thinking in terms of "each component fetches what it needs on the server." Third-party library compatibility is another pain point, as many popular React libraries assume a browser environment and need Client Component wrappers.

How much performance improvement can you expect from the App Router?

Results vary, but by leveraging React Server Components, teams commonly see 30-50% reductions in client-side JavaScript bundle size, leading to faster page loads and better Core Web Vitals scores. The improvement depends heavily on the ratio of data-display components to interactive components in your application. A reporting dashboard that mostly renders tables and charts will see massive gains because all of that rendering logic stays on the server. An application with heavy client-side interactivity will see smaller improvements. Beyond bundle size, the nested layout system prevents unnecessary re-renders during navigation, which improves perceived performance throughout the application.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Performance Optimization for an Image-Heavy Rental Platform
Feb 05, 20269 min read
Performance
Images
Next.js

Performance Optimization for an Image-Heavy Rental Platform

How we optimized Core Web Vitals for an image-heavy rental platform, reducing LCP from 4.2s to 1.8s through responsive images, lazy loading, blur placeholders, and CDN-based image transformation.