Next.js 28 ▲ Migration Mastery — Pages Router to App Router Transition
The Pages Router served Next.js well for years. Every file in pages/ was a route, every page received props from getServerSideProps or getStaticProps, and every component ran on both server and client by default. It was predictable, and it worked. The App Router changes the contract entirely. Components are Server Components by default, data fetching is colocated inside async functions rather than extracted into named exports, and the boundary between server and client is explicit rather than assumed. Migrating is not a refactor of syntax; it is a rethinking of where rendering happens and why.
The good news is that the two routers can coexist. The app/ directory can be introduced alongside pages/, and routes can be migrated one at a time without a big-bang rewrite. The bad news is that the migration path is full of silent failures. A next/head call inside a Server Component renders nothing and produces no error, quietly stripping page titles from production. A fetch call that assumed default caching now runs dynamically because Next.js 15 changed the default to uncached. A useRouter hook imported from next/router throws at runtime because it was never meant for the new directory. None of these fail at build time. They fail in production, which is the worst place to discover them.
This chapter covers the mechanics of migrating from Pages Router to App Router. It explains why the migration is architectural rather than syntactic, how to convert the four major data-fetching patterns (getStaticProps, getServerSideProps, getStaticPaths, and API routes), how to handle the routing hook changes and the 'use client' boundary, and the complete example session that walks through a realistic page migration from start to finish. The sections that follow cover the structural reasons the migration is not mechanical, the mapping of old APIs to new ones, and the specific pitfalls that produce silent regressions.
Key point: The Pages Router and App Router answer different questions. The Pages Router asks “what props does this page need?” The App Router asks “what renders on the server, what renders on the client, and what streams in between?” Migrating means answering the second question for every route, not translating the first into new syntax.
Why the migration is architectural, not mechanical
The Server Component default. In the Pages Router, every page component is a Client Component that happens to be pre-rendered on the server. It can use useState, useEffect, and any browser API because it hydrates in the browser. In the App Router, every component is a Server Component until a 'use client' directive says otherwise. A Server Component cannot use stateful hooks, cannot attach event handlers, and cannot access window or document. This means a page with a single useState call cannot be moved verbatim. It must be split: the data fetching moves to the Server Component, and the interactivity moves to a Client Component that receives data as props. The Next.js documentation recommends this exact two-step pattern as the easiest migration path: move the old page into a Client Component, then create a Server Component page.tsx that fetches data and passes it down.
The data fetching inversion. The Pages Router extracted data fetching into separate exported functions. getStaticProps returned props; getServerSideProps returned props; the page component received them and rendered. The App Router colocates data fetching inside the component itself. An async Server Component calls fetch directly, awaits the result, and renders it. There is no props object threaded through a function boundary. This is simpler in practice, but it breaks the mental model of “the page receives data.” The page is the data fetcher. The caching behavior is controlled by the fetch options or by route segment configuration, not by which named export you used.
The caching default reversal. Under the Pages Router, fetch inside getStaticProps was cached by default. Under the App Router in Next.js 15 and later, fetch is not cached by default. A page that relied on implicit caching now revalidates on every request, which may be correct or may be a performance regression depending on intent. Migrating a page without explicitly setting cache: 'force-cache' or next: { revalidate: N } changes its behavior silently. The migration must decide, per route, whether the data should be static, revalidated, or dynamic.
The trade-off. The App Router offers better performance through Server Components, streaming, and granular caching. It also requires more explicit decisions. The Pages Router made decisions for you, and sometimes those decisions were wrong. The App Router gives you the control to make them right, but only if you actually make them. A migration that mechanically translates APIs without reconsidering the rendering strategy will inherit the old router’s problems in a new directory structure.
a. Converting data fetching: getStaticProps, getServerSideProps, getStaticPaths
The three Pages Router data functions map to distinct App Router patterns. The mapping is not one-to-one in syntax, but the intent translates cleanly once the caching model is understood.
// pages/blog/[slug].tsx — Pages Router
import { GetStaticProps, GetStaticPaths } from 'next';
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetchAllPosts();
return {
paths: posts.map(post => ({ params: { slug: post.slug } })),
fallback: 'blocking',
};
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetchPost(params!.slug as string);
return {
props: { post },
revalidate: 3600, // ISR: revalidate every hour
};
};
export default function BlogPost({ post }: { post: Post }) {
return <article>{post.title}</article>;
}
// app/blog/[slug]/page.tsx — App Router
import { notFound } from 'next/navigation';
// generateStaticParams replaces getStaticPaths.
// It returns an array of params objects to prerender at build time.
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
// Route segment config replaces the revalidate option in getStaticProps.
// This tells Next.js to cache the page and revalidate every 3600 seconds.
export const revalidate = 3600;
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
// params is async in Next.js 15+. Await it before use.
const { slug } = await params;
// fetch with force-cache is the default for static generation.
// Without explicit caching, this page would become dynamic.
const post = await fetchPost(slug);
if (!post) {
notFound(); // Renders the nearest not-found.tsx
}
return <article>{post.title}</article>;
}
The getStaticPaths → generateStaticParams conversion is direct: both return an array of param objects. The revalidate value moves from the return object of getStaticProps to a route segment export. The fallback: 'blocking' behavior is the default in the App Router; unknown paths are generated on demand and cached. The critical addition is params: Promise<...> in the type signature. In Next.js 15 and later, params and searchParams are async and must be awaited.
For getServerSideProps, the conversion is simpler in structure but requires an explicit caching decision. A page that was server-rendered on every request should use cache: 'no-store' or the dynamic = 'force-dynamic' route segment config. A page that was server-rendered but could tolerate caching should use a revalidation interval. The old function name told you what happened; the new code requires you to state it.
b. Routing hooks and the ‘use client’ boundary
The Pages Router’s useRouter from next/router does not work in the App Router. The replacement is imported from next/navigation and has a different API. The pathname is no longer a property of the router object; it is a separate hook. The query object is split into useSearchParams and useParams.
// components/SearchBar.tsx — Pages Router
import { useRouter } from 'next/router';
export function SearchBar() {
const router = useRouter();
// router.pathname and router.query are both available.
const { pathname, query } = router;
return (
<input
defaultValue={query.q as string}
onChange={e => router.push({ pathname, query: { q: e.target.value } })}
/>
);
}
// components/SearchBar.tsx — App Router
'use client'; // Required: this component uses hooks and browser events.
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
export function SearchBar() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// searchParams.get() replaces the old query object access.
const currentQuery = searchParams.get('q') ?? '';
const handleChange = (value: string) => {
// Build a new URLSearchParams from the current params.
const params = new URLSearchParams(searchParams.toString());
params.set('q', value);
router.push(`${pathname}?${params.toString()}`);
};
return <input defaultValue={currentQuery} onChange={e => handleChange(e.target.value)} />;
}
The 'use client' directive is mandatory for any component using these hooks. The new hooks are only supported in Client Components. This means a page that previously imported useRouter at the top level and used it throughout must be split: the routing logic moves to a Client Component, and the page shell becomes a Server Component that renders it. The Next.js migration guide recommends moving the entire old page into a Client Component as the first step, then extracting Server Components incrementally as needed. This “client-first” migration is the safest path because it preserves the old behavior exactly. The optimization comes later, when you identify which parts of the page do not need to be client-side and move them back to the server.
Complete Example Session
// ============================================
// PART 1: THE PAGES ROUTER PAGE (BEFORE)
// ============================================
// pages/products/[id].tsx
import { GetServerSideProps } from 'next';
import { useState } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
interface Product {
id: string;
name: string;
price: number;
}
export const getServerSideProps: GetServerSideProps = async ({ params, req }) => {
// Fetch product data on every request.
const res = await fetch(`https://api.example.com/products/${params!.id}`);
const product: Product = await res.json();
// Read a cookie from the request object.
const theme = req.cookies.theme || 'light';
return { props: { product, theme } };
};
export default function ProductPage({ product, theme }: { product: Product; theme: string }) {
const router = useRouter();
const [quantity, setQuantity] = useState(1);
return (
<>
<Head>
<title>{product.name} — Shop</title>
</Head>
<div className={theme}>
<h1>{product.name}</h1>
<p>${product.price}</p>
<button onClick={() => setQuantity(q => q + 1)}>Qty: {quantity}</button>
<button onClick={() => router.push('/cart')}>Go to Cart</button>
</div>
</>
);
}
// ============================================
// PART 2: CREATING THE APP DIRECTORY STRUCTURE
// ============================================
// The app directory is created alongside pages.
// No existing pages are moved yet.
//
// app/
// ├── layout.tsx ← required root layout
// └── products/
// └── [id]/
// ├── page.tsx ← Server Component
// └── product-client.tsx ← Client Component
//
// Both routers coexist. The dev server serves routes
// from app/ first, falling back to pages/.
// ============================================
// PART 3: THE ROOT LAYOUT
// ============================================
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Shop',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// The root layout must contain <html> and <body>.
// This replaces the _app.tsx and _document.tsx roles.
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
// ============================================
// PART 4: THE CLIENT COMPONENT (INTERACTIVITY)
// ============================================
// app/products/[id]/product-client.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface Product {
id: string;
name: string;
price: number;
}
export function ProductClient({ product }: { product: Product }) {
// The old page's interactive parts move here.
const router = useRouter();
const [quantity, setQuantity] = useState(1);
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<button onClick={() => setQuantity(q => q + 1)}>Qty: {quantity}</button>
<button onClick={() => router.push('/cart')}>Go to Cart</button>
</div>
);
}
// ============================================
// PART 5: THE SERVER COMPONENT (DATA + SEO)
// ============================================
// app/products/[id]/page.tsx
import { cookies } from 'next/headers';
import { ProductClient } from './product-client';
interface Product {
id: string;
name: string;
price: number;
}
// generateMetadata replaces the <Head> component.
// It runs on the server and returns the metadata object.
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const res = await fetch(`https://api.example.com/products/${id}`, {
cache: 'no-store',
});
const product: Product = await res.json();
return {
title: `${product.name} — Shop`,
};
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// getServerSideProps equivalent: cache: 'no-store' means
// this fetch runs on every request, just like before.
const res = await fetch(`https://api.example.com/products/${id}`, {
cache: 'no-store',
});
const product: Product = await res.json();
// cookies() is async in Next.js 15+.
const cookieStore = await cookies();
const theme = cookieStore.get('theme')?.value ?? 'light';
return (
<div className={theme}>
{/* Pass the fetched data to the Client Component.
The product object must be serializable. */}
<ProductClient product={product} />
</div>
);
}
// ============================================
// PART 6: MIGRATING AN API ROUTE
// ============================================
// pages/api/cart.ts — Pages Router API route
// export default function handler(req, res) {
// if (req.method === 'POST') {
// const { productId, quantity } = req.body;
// res.status(200).json({ success: true });
// }
// }
// app/api/cart/route.ts — App Router Route Handler
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// request.json() replaces req.body parsing.
const { productId, quantity } = await request.json();
// Return a Response object instead of calling res.status().
return NextResponse.json({ success: true }, { status: 200 });
}
// ============================================
// PART 7: THE generateStaticParams MIGRATION
// ============================================
// pages/guides/[slug].tsx — getStaticPaths
// export async function getStaticPaths() {
// const guides = await fetchAllGuides();
// return { paths: guides.map(g => ({ params: { slug: g.slug } })), fallback: 'blocking' };
// }
// app/guides/[slug]/page.tsx — generateStaticParams
export async function generateStaticParams() {
const guides = await fetchAllGuides();
// Return an array of param objects.
// The fallback: 'blocking' behavior is the default.
return guides.map(guide => ({ slug: guide.slug }));
}
// ============================================
// PART 8: THE fetch CACHING DECISION
// ============================================
// The single most dangerous migration bug:
// fetch is NOT cached by default in Next.js 15+.
// ❌ This page is now dynamic, even though it looks static.
export default async function Page() {
const data = await fetch('https://api.example.com/data').then(r => r.json());
return <div>{data.title}</div>;
}
// ✅ To preserve getStaticProps behavior, cache explicitly.
export default async function StaticPage() {
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache', // or next: { revalidate: 3600 }
}).then(r => r.json());
return <div>{data.title}</div>;
}
// ============================================
// PART 9: REMOVING THE OLD PAGE
// ============================================
// Once the app/ route is verified in production:
// 1. Delete pages/products/[id].tsx
// 2. If no pages remain, delete the pages/ directory
// 3. Remove next/router imports from the codebase
// 4. Run: npx @next/codemod@latest app-router-migration ./pages
// to catch any remaining pages that should be migrated.
// ============================================
// PART 10: VERIFICATION CHECKLIST
// ============================================
// After each route migration:
// [ ] The route renders at the same URL
// [ ] Data fetching works with the intended caching behavior
// [ ] SEO metadata appears in the HTML source (view-source, not inspect)
// [ ] Interactive elements work (buttons, forms, routing)
// [ ] No console errors about hooks or client boundaries
// [ ] Build passes: next build
// [ ] Production preview works: next start
// [ ] No mixed Router imports: next/router must not appear in app/
The ten parts walked through a complete migration of a single dynamic route with data fetching, SEO metadata, cookies, and client interactivity. The pattern generalizes: create the app/ structure, move interactivity to a Client Component, move data fetching to the Server Component, replace getServerSideProps with explicit cache: 'no-store', replace <Head> with generateMetadata, and verify against the checklist before deleting the old page.
Quick Reference
Pages Router to App Router mapping
| Pages Router | App Router |
|---|---|
pages/index.tsx | app/page.tsx |
pages/about.tsx | app/about/page.tsx |
pages/blog/[slug].tsx | app/blog/[slug]/page.tsx |
getStaticProps | async Server Component + fetch with cache: 'force-cache' |
getServerSideProps | async Server Component + fetch with cache: 'no-store' |
getStaticPaths | generateStaticParams |
revalidate in return | export const revalidate = N |
pages/api/*.ts | app/api/*/route.ts |
_app.tsx | app/layout.tsx |
_document.tsx | app/layout.tsx with <html> |
<Head> / next/head | export const metadata or generateMetadata |
useRouter from next/router | useRouter, usePathname, useSearchParams from next/navigation |
Client Component boundary rules
| Scenario | Directive | Reason |
|---|---|---|
Uses useState, useEffect, useRef | 'use client' | Stateful hooks not allowed on server |
Attaches onClick, onChange | 'use client' | Event handlers are browser-only |
Uses useRouter from next/navigation | 'use client' | Routing hooks are client-only |
Fetches data with async/await | No directive (Server Component) | Async components only work on server |
Reads cookies() or headers() | No directive (Server Component) | Request APIs are server-only |
Caching behavior after migration
| Old behavior | Old default | App Router equivalent |
|---|---|---|
getStaticProps | Cached at build | fetch(url, { cache: 'force-cache' }) |
getStaticProps + revalidate | ISR | fetch(url, { next: { revalidate: N } }) or export const revalidate = N |
getServerSideProps | Dynamic every request | fetch(url, { cache: 'no-store' }) or export const dynamic = 'force-dynamic' |
Best Practices
✅ Do This:
// Await async params in Next.js 15+
const { id } = await params; // ✅
// Move interactivity to a Client Component
'use client'; export function Counter() { /* ... */ } // ✅
// Use generateMetadata for SEO
export async function generateMetadata({ params }) { /* ... */ } // ✅
// Set explicit caching on fetch
fetch(url, { cache: 'force-cache' }); // ✅
// Use notFound() for missing data
if (!post) notFound(); // ✅
// Import routing hooks from next/navigation
import { useRouter } from 'next/navigation'; // ✅
// Keep pages/ directory during incremental migration
// Migrate one route at a time // ✅
❌ Don’t Do This:
// Use next/router inside app/
import { useRouter } from 'next/router'; // ❌
// Use <Head> inside a Server Component
import Head from 'next/head'; // ❌ (renders nothing)
// Assume fetch is cached by default
const data = await fetch(url); // ❌ (uncached in v15+)
// Use stateful hooks in a Server Component
export default async function Page() { const [x] = useState(); } // ❌
// Access params without awaiting
const { slug } = params; // ❌ (Promise in v15+)
// Return { props } from a Server Component
export default async function Page() { return { props: {} }; } // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Page title missing in production | <Head> used inside Server Component; renders null silently | Replace with metadata export or generateMetadata |
| Page became dynamic unexpectedly | fetch not cached by default since v15 | Add cache: 'force-cache' or next: { revalidate: N } |
useRouter throws “NextRouter not mounted” | Imported from next/router in app/ | Import from next/navigation; add 'use client' |
params is undefined or a Promise | Next.js 15+ made params async | const { id } = await params |
| State hooks crash the page | Server Component used useState | Extract to a Client Component with 'use client' |
| Cookie read returns undefined | cookies() is async in v15+ | const store = await cookies() |
| API route 404s after migration | Route Handler uses wrong export name | Use export async function GET/POST in route.ts |
Real-World Examples
1. Static page with generateStaticParams
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
2. Server-side rendered page with no cache
export const dynamic = 'force-dynamic';
const data = await fetch(url, { cache: 'no-store' });
3. Client Component for interactivity
'use client';
export function AddToCart({ id }: { id: string }) {
return <button onClick={() => cart.add(id)}>Add</button>;
}
4. Dynamic metadata from fetched data
export async function generateMetadata({ params }) {
const post = await getPost((await params).slug);
return { title: post.title };
}
5. Route Handler replacing API route
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ ok: true });
}
6. Awaiting cookies
const theme = (await cookies()).get('theme')?.value;
7. Reading search params in a Client Component
const searchParams = useSearchParams();
const query = searchParams.get('q');
8. notFound() for missing resources
const post = await getPost(slug);
if (!post) notFound();
9. Incremental migration with both routers
// app/dashboard/page.tsx exists alongside pages/legacy.tsx
// The dev server serves app/ routes first.
10. Codemod for mechanical migration
npx @next/codemod@latest app-router-migration ./pages
Visual
The Two-Step Page Migration Pattern
┌──────────────────────────────────────────────────────────────┐
│ FROM PAGES TO APP: ONE ROUTE AT A TIME │
│ │
│ STEP 1: Move the old page into a Client Component. │
│ │
│ pages/products.tsx │
│ ┌─────────────────────────────┐ │
│ │ export default function() │ │
│ │ useState, useRouter │ │
│ │ onClick handlers │ │
│ └──────────────┬──────────────┘ │
│ │ copy into │
│ ▼ │
│ app/products/product-client.tsx │
│ ┌─────────────────────────────┐ │
│ │ 'use client' │ │
│ │ export function Client() │ │
│ │ (same code, same behavior)│ │
│ └──────────────┬──────────────┘ │
│ │ │
│ STEP 2: Create a Server Component that fetches and renders. │
│ │ │
│ ▼ │
│ app/products/page.tsx │
│ ┌─────────────────────────────┐ │
│ │ export default async function│ │
│ │ const data = await fetch │ │
│ │ return <Client data={data}/> │
│ └─────────────────────────────┘ │
│ │
│ The interactivity is preserved. The data moves to the server.│
│ SEO moves to generateMetadata. The old page is deleted. │
└──────────────────────────────────────────────────────────────┘
The Silent Failure Zones
┌──────────────────────────────────────────────────────────────┐
│ WHAT BREAKS WITHOUT AN ERROR │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ <Head> in Server │ │ fetch() without │ │
│ │ Component │ │ cache option │ │
│ │ │ │ │ │
│ │ Renders null. │ │ Runs dynamically. │ │
│ │ No title in HTML. │ │ No build error. │ │
│ │ SEO regression. │ │ Performance drops. │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ useRouter from │ │ params not awaited │ │
│ │ next/router in app/ │ │ in Next.js 15+ │ │
│ │ │ │ │ │
│ │ Throws at runtime. │ │ Undefined values. │ │
│ │ "NextRouter not │ │ fetch(undefined). │ │
│ │ mounted." │ │ 404s in production. │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ These do not fail the build. They fail the user. │
└──────────────────────────────────────────────────────────────┘
Data Fetching Mapping
┌──────────────────────────────────────────────────────────────┐
│ THE FOUR OLD PATTERNS AND THEIR NEW HOMES │
│ │
│ Pages Router App Router │
│ ───────────── ────────── │
│ │
│ getStaticProps → async Component + │
│ (build-time data) fetch(cache: 'force-cache') │
│ │
│ getStaticProps + → async Component + │
│ revalidate fetch(next: { revalidate }) │
│ OR export const revalidate │
│ │
│ getServerSideProps → async Component + │
│ (request-time data) fetch(cache: 'no-store') │
│ │
│ getStaticPaths → generateStaticParams() │
│ (dynamic paths) (returns param array) │
│ │
│ The function names disappear. The caching intent │
│ becomes explicit in the fetch call or segment config. │
└──────────────────────────────────────────────────────────────┘
Incremental Migration with Both Routers
┌──────────────────────────────────────────────────────────────┐
│ COEXISTENCE DURING MIGRATION │
│ │
│ pages/ app/ │
│ ├── index.tsx ├── layout.tsx │
│ ├── about.tsx ┌───────▶├── about/page.tsx │
│ ├── products/[id].tsx │ ├── products/[id]/page.tsx │
│ └── api/cart.ts │ └── api/cart/route.ts │
│ │ │
│ Routes not yet migrated │ Routes migrated │
│ still work from pages/ │ served from app/ │
│ │ │
│ ┌──────────────────────┴──────────────────────┐ │
│ │ Next.js dev server checks app/ first. │ │
│ │ If no match, falls back to pages/. │ │
│ │ Both routers run simultaneously. │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Migrate one route. Test it. Delete the old page. Repeat. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Root layout requirement | app/layout.tsx with <html> and <body> |
Default component type in app/ | Server Component |
| Directive for client interactivity | 'use client' |
getStaticProps replacement | async component + fetch(cache: 'force-cache') |
getServerSideProps replacement | async component + fetch(cache: 'no-store') |
getStaticPaths replacement | generateStaticParams() |
| SEO replacement | export const metadata or generateMetadata() |
| Routing hook import | next/navigation (not next/router) |
| Params type in Next.js 15+ | Promise<{ ... }> (must await) |
| API route replacement | app/api/*/route.ts with named exports |
Key takeaways:
- The two-step pattern is the safest migration path. Move the old page into a Client Component first, preserving all behavior. Then create a Server Component that fetches data and renders the Client Component. This works for every page, regardless of complexity, and it defers the optimization decision until the route is verified.
- The caching default changed silently.
fetchis not cached by default in Next.js 15+. AgetStaticPropspage migrated withoutcache: 'force-cache'becomes dynamic. This is not an error; it is a behavior change that must be intentional. <Head>andnext/headdo nothing in Server Components. They render null without a warning. Page titles and meta descriptions disappear from production HTML. Every migrated page must replace<Head>withmetadataorgenerateMetadatabefore the old page is deleted.paramsis async in Next.js 15+. The old synchronous accessparams.slugbecomes(await params).slug. The codemod handles most of these, but hand-written route handlers and type signatures often need manual correction.- The old router hooks do not work in the new directory.
useRouterfromnext/routerthrows at runtime. The replacement isnext/navigationwith separateusePathnameanduseSearchParamshooks. Components using them must be Client Components.
Remember: Migration is not a single event. It is a sequence of decisions about rendering strategy, caching behavior, and the server-client boundary, made one route at a time. The Pages Router made those decisions for you. The App Router requires you to make them. The codemods handle the syntax. The judgment is yours. A migrated page that renders the same markup but fetches data dynamically when it used to be static is not a successful migration; it is a performance regression with a new file structure. Verify the caching behavior, check the HTML source for metadata, and test the production build before deleting the old page. The old router will keep serving traffic until you turn it off. Use that time to be certain.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!