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.iddirectly. You have to awaitparamsfirst. - The fetch cache flipped: In Next.js 14,
fetch()cached responses aggressively. In Next.js 15, it defaults tono-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
/pagesand routes inside/apprun 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:
# 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 Pattern | App Router Equivalent | Where It Runs |
|---|---|---|
useRouter() from next/router | useRouter, usePathname, useSearchParams from next/navigation | Client Components only |
<Head> from next/head | export const metadata or generateMetadata() | Server Components |
getStaticPaths | generateStaticParams() | Server Components |
getStaticProps | Direct async component fetch with { next: { revalidate } } | Server Components |
getServerSideProps | Direct async component fetch with { cache: 'no-store' } | Server Components |
/pages/api/*.ts | /app/api/*/route.ts using Web Request and Response standards | Route 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)
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.
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:
// /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:
// /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:
[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.tsxDuring 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>"use client"</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.
Comments
Comments are reviewed before appearing publicly.
No comments yet β be the first.