How We Migrated a Production Next.js App from Pages to App Router: Next.js 15 Architecture and Real-World Gotchas

A hands-on guide by Via Mae on migrating a production Next.js app from Pages Router to Next.js 15 App Router. Covers async params, Server Components, and cache tagging.

SB

SmartBuddy Engineering Team

Autonomous Systems & AI Tools, MCP & Dev
How We Migrated a Production Next.js App from Pages to App Router: Next.js 15 Architecture and Real-World Gotchas

Two weeks ago, Amir and I took on what looked like a straightforward upgrade for a client dashboard. The goal was simple: move from the legacy Pages Router to the App Router on Next.js 15 to drop client bundle sizes and get access to React Server Components.

If you read quick tutorials online, you might think migration is just making an app folder and dropping files into it.

It is not.

Within thirty minutes of our first test run, our layout broke, client context crashed, and API calls started behaving unpredictably. Next.js 15 also quietly changed two major runtime fundamentals: route parameters are now asynchronous Promises, and native fetch caching flipped from automatic caching to dynamic execution (no-store).

Here is the exact step-by-step pipeline we built to migrate the entire application without breaking production or forcing a risky weekend rewrite.

What Caught Us Off Guard First

When moving a real app, the friction rarely comes from standard React components. It comes from assumptions baked into older routing habits.

  • Route parameters are now Promises: In Next.js 15, you cannot read params.id directly. You have to await params first.
  • The fetch cache flipped: In Next.js 14, fetch() cached responses aggressively. In Next.js 15, it defaults to no-store. If you relied on automatic caching, your server traffic suddenly spikes.
  • Client boundaries leak easily: Putting "use client" at the top of a page to keep using an old hook turns the entire page back into a client bundle.
  • Hybrid routing actually works: You do not need to migrate everything at once. Pages inside /pages and routes inside /app run happily side-by-side during development.

Step 1: Auditing Old Router Dependencies

Before renaming a single folder, Amir and I audited the codebase for every legacy Pages Router import. If you skip this, you will spend days chasing mysterious undefined errors.

Here are the terminal commands we ran to map our migration footprint:

bash
# Scan for legacy router and document dependencies
grep -rn "from 'next/router'" pages/ components/ lib/
grep -rn "from 'next/head'" pages/ components/
grep -rn "getStaticPaths" pages/
grep -rn "getInitialProps" pages/ _app.tsx _document.tsx
grep -rln "window\.\|document\." components/ pages/

The Replacement Cheatsheet

Legacy Pages PatternApp Router EquivalentWhere It Runs
useRouter() from next/routeruseRouter, usePathname, useSearchParams from next/navigationClient Components only
<Head> from next/headexport const metadata or generateMetadata()Server Components
getStaticPathsgenerateStaticParams()Server Components
getStaticPropsDirect async component fetch with { next: { revalidate } }Server Components
getServerSidePropsDirect async component fetch with { cache: 'no-store' }Server Components
/pages/api/*.ts/app/api/*/route.ts using Web Request and Response standardsRoute Handlers

Step 2: Converting getServerSideProps to React Server Components

In the old Pages Router, every server data call had to live inside getServerSideProps. That meant writing boilerplate to serialize data, passing it through props, and shipping that code down to the browser.

The Old Way (/pages/products/[id].tsx)

tsx
import { GetServerSideProps } from 'next';

interface ProductProps {
  product: { id: string; name: string; price: number };
}

export default function ProductPage({ product }: ProductProps) {
  return (
    <div className="product-box">
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </div>
  );
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  const { id } = context.params || {};
  const res = await fetch(`https://api.example.com/products/${id}`);
  const product = await res.json();
  return { props: { product } };
};

This approach forced client bundles to carry data schemas and parsing logic, slowing down page loads.

The Modern Next.js 15 Way (/app/products/[id]/page.tsx)

In the App Router, pages are React Server Components by default. We fetch data directly inside the async function.

Notice how we handle params in Next.js 15: params is typed as a Promise and must be unwrapped with await.

tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { RefreshButton } from './RefreshButton';
import { refreshProductAction } from './actions';

interface PageProps {
  params: Promise<{ id: string }>; // Next.js 15 Promise type
}

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    // Explicit cache tagging for surgical updates
    next: { tags: [`product-${id}`, 'products'] }
  });
  
  if (!res.ok) return null;
  return res.json();
}

export default async function ProductPage({ params }: PageProps) {
  const { id } = await params; // Must await in Next.js 15
  const product = await getProduct(id);

  if (!product) {
    notFound();
  }

  return (
    <main className="max-w-4xl mx-auto p-6 space-y-6">
      <div>
        <h1 className="text-3xl font-bold text-slate-900">{product.name}</h1>
        <p className="text-xl text-slate-700">${product.price}</p>
      </div>

      {/* Progressive enhancement via Server Action */}
      <form action={refreshProductAction.bind(null, id)}>
        <RefreshButton />
      </form>

      {/* Suspense streaming for slower secondary data */}
      <Suspense fallback={<div className="p-4 border rounded animate-pulse">Loading reviews...</div>}>
        <AsyncReviewsSection productId={id} />
      </Suspense>
    </main>
  );
}

async function AsyncReviewsSection({ productId }: { productId: string }) {
  const res = await fetch(`https://api.example.com/products/${productId}/reviews`, {
    next: { revalidate: 1800 }
  });
  const reviews = await res.json();

  return (
    <section className="border-t pt-4">
      <h2 className="text-xl font-semibold mb-3">Verified Feedback</h2>
      <div className="space-y-2">
        {reviews.map((item: { id: string; author: string; comment: string }) => (
          <div key={item.id} className="p-3 bg-slate-50 rounded">
            <span className="font-medium text-slate-800">{item.author}:</span>
            <p className="text-slate-600 text-sm mt-1">{item.comment}</p>
          </div>
        ))}
      </div>
    </section>
  );
}

Step 3: Granular Cache Invalidation with Server Actions

One mistake we made early on was calling revalidatePath on every update. It works, but it purges the entire page cache and forces unnecessary re-renders.

Instead, we tag specific fetch calls using next: { tags: [...] } and invalidate only that exact record inside a Server Action:

tsx
// /app/products/[id]/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function refreshProductAction(id: string) {
  // Purges only cached queries matching this specific product tag
  revalidateTag(`product-${id}`);
}

To give users immediate feedback when they click refresh, we created a small client component using useFormStatus:

tsx
// /app/products/[id]/RefreshButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function RefreshButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded text-sm font-medium disabled:opacity-50"
    >
      {pending ? 'Refreshing cache...' : 'Sync Latest Price'}
    </button>
  );
}

Because "use client" stays isolated inside RefreshButton.tsx, the parent page remains a pure Server Component.

Step 4: The Four-Phase Migration Plan We Used

Here is the exact rollout path that kept our app running throughout the refactor:

code
[Phase 1: Root Layout]
  β”œβ”€β”€ Create app/layout.tsx and app/globals.css
  └── Verify hybrid routing (existing /pages routes still work)
          β”‚
[Phase 2: Static Leaf Pages]
  β”œβ”€β”€ Migrate /pages/about.tsx and marketing pages
  └── Switch to export const metadata
          β”‚
[Phase 3: Dynamic Data Routes]
  β”œβ”€β”€ Migrate /pages/products/[id].tsx -> /app/products/[id]/page.tsx
  β”œβ”€β”€ Refactor getServerSideProps to async Server Components
  └── Wire up tag-based revalidation with Server Actions
          β”‚
[Phase 4: Route Handlers and Cleanup]
  β”œβ”€β”€ Move /pages/api endpoints to /app/api/*/route.ts
  └── Remove _document.tsx and _app.tsx

During this entire process, Next.js keeps routing both directories in sync without extra config on our end. If a route exists in both /app and /pages, /app takes priority.

Frequently Asked Questions

Why does Next.js 15 force params to be a Promise?

Next.js 15 changed <code>params</code> and <code>searchParams</code> to Promises so the server can stream page shells before route parameters finish resolving. If you forget to await <code>params</code>, TypeScript will flag it or you will see unexpected runtime errors.

What happens to client context providers?

You can still use them! Wrap them in a separate client component (like <code>components/Providers.tsx</code>) marked with <code>&quot;use client&quot;</code>. Wrap that component around <code>{children}</code> inside <code>app/layout.tsx</code>. Any Server Component passed as children to a client component still runs on the server.

Did you find this technical breakdown helpful?

Tap to rate this guide · 7 views

Comments

Comments are reviewed before appearing publicly.

No comments yet β€” be the first.

πŸš€ Ready to Deploy Autonomous Skills in Production?

Get this skill (and 29 more) in the SmartBuddy Shop, or work with our engineering team to architect custom multi-agent workflows for your company.