Surviving the Next.js App Router Migration - A Tale of Triumph and Tears

nextjs app-router react web-development migration

My 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:

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:

  1. Routing: Obviously, all pages had to move from /pages to /app
  2. Data Fetching: All getServerSideProps and getStaticProps needed replacing
  3. API Routes: Had to move from /pages/api to /app/api
  4. Layout Components: Our custom layout system needed to use Next.js's new layout files
  5. Authentication: Our auth flow with NextAuth needed updating
  6. Client-Side State: Redux was heavily used throughout the app
  7. 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:

  1. Mark everything that uses Redux as a Client Component (defeating many benefits of the App Router)
  2. Move away from Redux entirely (a massive undertaking)
  3. 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:

  1. Faster Page Loads: Initial page load times decreased by approximately 30% on average
  2. Smaller Bundle Sizes: Our JavaScript payload decreased by around 25%
  3. Improved Core Web Vitals: Particularly Largest Contentful Paint and First Input Delay

The improvements came from several aspects of the App Router:

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:

  1. Start with a deep understanding before coding: The time I spent learning the App Router concepts paid off tremendously.

  2. Incremental migration is essential: The ability to run both routers side by side was crucial for our success.

  3. Rethink component boundaries: The Server/Client Component split forced us to reconsider how we structure our application, ultimately leading to better architecture.

  4. State management needs reconsideration: Traditional client-state approaches like Redux require careful thought in the Server Component world.

  5. Testing is more important than ever: The new paradigm introduced new types of potential issues, making comprehensive testing vital.

  6. 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:

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.