Surviving the Next.js App Router Migration - A Tale of Triumph and Tears
nextjs app-router react web-development migrationMy personal journey migrating a complex application from Next.js Pages Router to App Router, with all the pitfalls and victories along the way
The Email That Changed Everything
It was a sunny Tuesday morning when our product manager sent an innocent-looking email: "We need to update our Next.js application to use the latest features. Can we plan this for the next sprint?"
Little did I know that this email would lead to weeks of refactoring, debugging, and occasionally staring at my screen in silent despair. But also, ultimately, to a better, faster, and more maintainable application.
You see, we had a substantial Next.js application built with the Pages Router. It had grown organically over three years, accumulating features, state management solutions, and authentication flows. The thought of migrating to the App Router felt like deciding to renovate the foundation of your house while still living in it.
But the promise was tantalizing: built-in server components, simplified data fetching, improved routing... So I agreed, and our journey began.
The First Steps: Understanding the New Paradigm
Before writing a single line of code, I spent days researching. The App Router wasn't just a different folder structure—it represented a fundamental shift in how Next.js applications are built.
The biggest mental hurdle was wrapping my head around Server and Client Components. In our Pages Router app, everything was a Client Component by default. We'd fetch data in getServerSideProps
or getStaticProps
, and then our page component would render with that data.
With the App Router, the default is Server Components. These components:
- Can directly access backend resources
- Don't increase your JavaScript bundle size
- Can't use React hooks or browser APIs
This seemingly simple change had massive implications for our codebase.
// Old approach with Pages Router
export default function ProductPage({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<div>
<button onClick={() => setQuantity(Math.max(1, quantity - 1))}>
-
</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
</div>
<button onClick={() => addToCart(product, quantity)}>Add to Cart</button>
</div>
);
}
export async function getServerSideProps(context) {
const { id } = context.params;
const product = await fetchProduct(id);
return {
props: {
product
}
};
}
This had to be rewritten for the App Router:
// New approach with App Router
// app/products/[id]/page.js (Server Component)
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<ProductActions product={product} />
</div>
);
}
// ProductActions.js (Client Component)
'use client';
import { useState } from 'react';
export default function ProductActions({ product }) {
const [quantity, setQuantity] = useState(1);
return (
<div>
<div>
<button onClick={() => setQuantity(Math.max(1, quantity - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(quantity + 1)}>+</button>
</div>
<button onClick={() => addToCart(product, quantity)}>Add to Cart</button>
</div>
);
}
The separation seemed logical once I understood it, but it required a fundamental rethinking of our component architecture.
The Inventory: What Needed to Change
Before diving into the migration, I took inventory of our application:
- Routing: Obviously, all pages had to move from
/pages
to/app
- Data Fetching: All
getServerSideProps
andgetStaticProps
needed replacing - API Routes: Had to move from
/pages/api
to/app/api
- Layout Components: Our custom layout system needed to use Next.js's new layout files
- Authentication: Our auth flow with NextAuth needed updating
- Client-Side State: Redux was heavily used throughout the app
- Third-Party Libraries: Some weren't compatible with Server Components
It was a daunting list. I decided to take an incremental approach – starting with creating the new app directory and migrating one simple page at a time.
The Coexistence Phase: Running Both Routers Side by Side
One of the saving graces of this migration was that Next.js allows the Pages Router and App Router to run simultaneously. This meant we could migrate piece by piece, testing each part thoroughly before moving on.
I started by setting up the basic structure:
// app/layout.js
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
</body>
</html>
);
}
// app/page.js
export default function Home() {
return (
<main>
<h1>Welcome to our store</h1>
<p>This page is using the App Router!</p>
</main>
);
}
At this point, navigating to /
would use the App Router version, while all other pages still used the Pages Router. It was a perfect toe-in-the-water approach.
The Authentication Challenge: NextAuth in the App Router
Our application had a robust authentication system built with NextAuth.js. Migrating this was one of my biggest concerns, as it was central to everything in our app.
Thankfully, NextAuth had updated for App Router, but the implementation was different. Instead of wrapping our pages with a provider, we needed to use a combination of Server Components, Client Components, and the new session management.
// app/layout.js
import { AuthProvider } from '@/components/AuthProvider';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
export default async function RootLayout({ children }) {
const session = await getServerSession(authOptions);
return (
<html lang="en">
<body>
<AuthProvider session={session}>
{children}
</AuthProvider>
</body>
</html>
);
}
// components/AuthProvider.js
'use client';
import { SessionProvider } from 'next-auth/react';
export function AuthProvider({ children, session }) {
return <SessionProvider session={session}>{children}</SessionProvider>;
}
// Protecting a route in a Server Component
// app/dashboard/page.js
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
}
return (
<div>
<h1>Dashboard</h1>
<p>Welcome, {session.user.name}!</p>
{/* Dashboard content */}
</div>
);
}
This seemed more complex at first, but I came to appreciate the flexibility. We could now check authentication directly in Server Components without client-side redirects for protected routes. And for client components that needed session data, we still had access to the useSession hook.
The Redux Dilemma: Server Components and Client State
Our application used Redux extensively for state management. The challenge was that Redux relies on context and hooks, which aren't available in Server Components.
I faced a decision:
- Mark everything that uses Redux as a Client Component (defeating many benefits of the App Router)
- Move away from Redux entirely (a massive undertaking)
- Find a middle ground by being more selective about where state lives
I chose option 3. For state that truly needed to be global and interactive, we kept Redux but isolated it to client components. For data that was primarily read-only, we moved to server fetching with occasional client refreshes.
// components/ReduxProvider.js
"use client";
import { Provider } from "react-redux";
import { store } from "@/lib/store";
export function ReduxProvider({ children }) {
return <Provider store={store}>{children}</Provider>;
}
// app/layout.js
import { ReduxProvider } from "@/components/ReduxProvider";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ReduxProvider>{children}</ReduxProvider>
</body>
</html>
);
}
// A component that needs Redux
// components/Cart.js
("use client");
import { useSelector, useDispatch } from "react-redux";
import { removeItem } from "@/lib/cartSlice";
export function Cart() {
const cartItems = useSelector((state) => state.cart.items);
const dispatch = useDispatch();
return (
<div>
<h2>Your Cart</h2>
{cartItems.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button onClick={() => dispatch(removeItem(item.id))}>Remove</button>
</div>
))}
</div>
);
}
This approach worked, but it felt like we were fighting against the framework rather than working with it. I made a mental note to gradually reduce our Redux usage in favor of more App Router-friendly state management patterns in the future.
The Data Fetching Revolution: Goodbye getServerSideProps
One of the most significant changes was in data fetching. Gone were getServerSideProps
and getStaticProps
, replaced by directly fetching data in Server Components.
This was actually a pleasant surprise—the new approach was more intuitive:
// app/products/page.js
export default async function ProductsPage() {
const products = await fetchProducts();
return (
<div>
<h1>Our Products</h1>
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
// lib/data.js
export async function fetchProducts() {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 } // Revalidate every hour
});
if (!res.ok) {
throw new Error("Failed to fetch products");
}
return res.json();
}
The simplicity was refreshing. No more passing data through props from specialized functions—just directly fetch and use the data where needed.
For client-side data fetching, we still had options like SWR or React Query, which worked well within Client Components.
The Unexpected Heroes: Loading and Error States
As I worked through the migration, I discovered some gems in the App Router that I hadn't anticipated. The built-in loading and error handling was one of them.
In our Pages Router app, loading states required careful state management and conditional rendering. With the App Router, we could create loading.js and error.js files that would automatically be used:
// app/products/loading.js
export default function Loading() {
return (
<div className="loading-container">
<div className="spinner"></div>
<p>Loading products...</p>
</div>
);
}
// app/products/error.js
'use client';
export default function Error({ error, reset }) {
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
This was much cleaner than our previous approach and encouraged us to think about these states for every route.
The SEO Upgrade: Metadata API
Another pleasant surprise was the new Metadata API. In our Pages Router app, we had a complex system of Head components from next/head. The new approach was more straightforward:
// app/products/[id]/page.js
export async function generateMetadata({ params }) {
const product = await fetchProduct(params.id);
return {
title: `${product.name} | Our Store`,
description: product.description,
openGraph: {
images: [{ url: product.imageUrl }]
}
};
}
export default async function ProductPage({ params }) {
const product = await fetchProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
{/* Product details */}
</div>
);
}
This API felt more natural and allowed for dynamic metadata based on the route parameters or fetched data.
The Dark Days: Debugging Server Components
Not everything was smooth sailing. Debugging Server Components proved particularly challenging. When something went wrong, the error messages often weren't helpful, and traditional debugging tools like React DevTools couldn't inspect Server Components.
I learned to add extensive console.log statements in my Server Components and to break complex components down into smaller pieces to isolate issues.
One particularly frustrating day involved tracking down why a seemingly simple Server Component was causing a "Error: Objects are not valid as a React child" error. After hours of debugging, I discovered the issue was in how I was handling date objects from our API:
// The problematic code
export default async function EventPage({ params }) {
const event = await fetchEvent(params.id);
return (
<div>
<h1>{event.name}</h1>
<p>Date: {event.date}</p> {/* event.date was a Date object! */}
{/* Other event details */}
</div>
);
}
// The fix
export default async function EventPage({ params }) {
const event = await fetchEvent(params.id);
return (
<div>
<h1>{event.name}</h1>
<p>Date: {event.date.toLocaleDateString()}</p>
{/* Other event details */}
</div>
);
}
These kinds of issues were much harder to track down compared to Client Component bugs, and I often resorted to binary search debugging—commenting out half the component at a time until I found the problematic part.
The Light at the End of the Tunnel: Performance Gains
After several weeks of migration, debugging, and refactoring, we finally had the entire application running on the App Router. It was time to measure the results.
To my delight, the performance improvements were significant:
- Faster Page Loads: Initial page load times decreased by approximately 30% on average
- Smaller Bundle Sizes: Our JavaScript payload decreased by around 25%
- Improved Core Web Vitals: Particularly Largest Contentful Paint and First Input Delay
The improvements came from several aspects of the App Router:
- Server Components reduced the client-side JavaScript
- Automatic component-level code splitting
- The more efficient routing system
- Streaming and Suspense integration
The most satisfying moment was when our product manager—the one who innocently requested this migration—noticed the performance improvement without being told about it. "The site feels snappier," he said during a demo, not realizing what a validation that was after weeks of hard work.
The Knowledge: Lessons for Future Migrations
Looking back on this migration journey, several key lessons stand out:
-
Start with a deep understanding before coding: The time I spent learning the App Router concepts paid off tremendously.
-
Incremental migration is essential: The ability to run both routers side by side was crucial for our success.
-
Rethink component boundaries: The Server/Client Component split forced us to reconsider how we structure our application, ultimately leading to better architecture.
-
State management needs reconsideration: Traditional client-state approaches like Redux require careful thought in the Server Component world.
-
Testing is more important than ever: The new paradigm introduced new types of potential issues, making comprehensive testing vital.
-
Document as you go: I kept detailed notes about patterns, workarounds, and decisions, which became invaluable as the migration progressed.
The Future: Embracing the New Paradigm
Now that we've completed the migration, we're beginning to leverage the App Router's capabilities more fully. We're exploring:
- Parallel routes for more complex layouts
- Intercepting routes for modals and slideovers
- Server Actions for form handling (still in beta when we migrated)
- More granular layouts to optimize loading performance
The migration was challenging—at times frustrating—but ultimately rewarding. Our application is now faster, more maintainable, and better positioned to take advantage of future Next.js innovations.
If you're considering a similar migration, my advice is to embrace the challenge but approach it methodically. The App Router isn't just a different way to organize files; it's a new way of thinking about web applications that aligns more closely with the platform's strengths.
The journey might be difficult, but the destination is worth it.