Loading…
Loading…
A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.
Tags
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 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:
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._app.tsx and per-page layout functions, but nested layouts required complex composition patterns that new developers constantly got wrong.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.
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:
The most significant paradigm shift was data fetching. In Pages Router, data flows through a single gateway:
// 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:
// 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>
);
}// 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.
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:
// 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>
);
}// 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>
);
}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:
// 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.
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.
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.
The migration delivered significant improvements across the board:
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.
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.
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.
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.
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
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.
How I turned a static portfolio into an AI-powered assistant using Next.js, the Vercel AI SDK, Gemini embeddings, and pgvector-backed retrieval over resume content.
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
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.