Next.js App Router: A Migration Guide from Pages Router
Why the Migration Is Worth the Pain
Let’s be direct: migrating from the Next.js Pages Router to the App Router is not a weekend project. When we migrated our internal project management tool at Harbor Software — 47 routes, authenticated layouts, server-side data fetching, and a dozen API endpoints — it took three weeks of focused effort. But the result was worth it: smaller bundles, simpler data fetching, elimination of getServerSideProps boilerplate, and native support for streaming and partial rendering.
The App Router isn’t just a different file convention. It represents a fundamentally different rendering model built on React Server Components. Understanding that distinction early will save you from the most common migration pitfalls.
This guide is structured as a migration playbook. We cover every major pattern — routing, layouts, data fetching, API routes, middleware, metadata, error handling — with before/after code and the gotchas we discovered in production.
Phase 0: Preparation (Before You Touch Any Code)
Before migrating a single route, do these three things:
1. Upgrade Next.js to the latest version
The App Router and Pages Router coexist in the same project. You don’t have to migrate all at once. But you need to be on Next.js 13.4+ (stable App Router) at minimum, and we recommend the latest 14.x for the best experience.
npm install next@latest react@latest react-dom@latest
2. Audit your current routes
Create a spreadsheet of every route, its data fetching method (getStaticProps, getServerSideProps, getStaticPaths), and whether it uses client-side interactivity. This becomes your migration checklist.
3. Identify shared layouts
In the Pages Router, shared layouts are typically implemented via a custom _app.tsx or wrapper components. In the App Router, layouts are first-class citizens with their own file convention. Map your existing layout hierarchy before migrating.
Phase 1: Routing and File Structure
The fundamental change: routes move from pages/ to app/, and the file naming convention changes.
Pages Router structure:
pages/
index.tsx # / (homepage)
about.tsx # /about
blog/
index.tsx # /blog
[slug].tsx # /blog/:slug
dashboard/
index.tsx # /dashboard
settings.tsx # /dashboard/settings
api/
users.ts # /api/users
_app.tsx # Global layout
_document.tsx # HTML document
404.tsx # Custom 404
App Router equivalent:
app/
page.tsx # / (homepage)
layout.tsx # Root layout (replaces _app + _document)
not-found.tsx # Custom 404
about/
page.tsx # /about
blog/
page.tsx # /blog
[slug]/
page.tsx # /blog/:slug
dashboard/
layout.tsx # Dashboard-specific layout
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
api/
users/
route.ts # /api/users
Key differences: every route segment is a folder. The actual page component lives in page.tsx inside that folder. Dynamic routes use the same bracket syntax but as folder names. API routes use route.ts instead of named files.
Phase 2: Layouts — The Biggest Win
The layout system is the single best improvement in the App Router. In the Pages Router, implementing persistent layouts that don’t unmount on navigation required workarounds:
// Pages Router: getLayout pattern (the old way)
// pages/dashboard/index.tsx
DashboardPage.getLayout = function getLayout(page) {
return (
<AppLayout>
<DashboardSidebar />
{page}
</AppLayout>
);
};
// pages/_app.tsx
function MyApp({ Component, pageProps }) {
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}
// App Router: native layout (the new way)
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
return (
<div className="flex min-h-screen">
<DashboardSidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
const stats = await getDashboardStats();
return <StatsGrid stats={stats} />;
}
// app/dashboard/settings/page.tsx
export default async function SettingsPage() {
const settings = await getUserSettings();
return <SettingsForm settings={settings} />;
}
The layout wraps both /dashboard and /dashboard/settings automatically. When navigating between them, the sidebar doesn’t unmount or re-render. State inside the sidebar (like an open/closed toggle) persists. This was painful to achieve in the Pages Router and it’s free in the App Router.
Phase 3: Data Fetching — The Paradigm Shift
This is where the migration gets conceptually challenging. The Pages Router has four data fetching methods, and the App Router replaces all of them with a single pattern: async Server Components.
getStaticProps (Static Generation)
// BEFORE: pages/blog/[slug].tsx
export async function getStaticProps({ params }) {
const post = await getPostBySlug(params.slug);
return {
props: { post },
revalidate: 3600, // ISR: regenerate every hour
};
}
export async function getStaticPaths() {
const posts = await getAllPostSlugs();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
};
}
export default function BlogPost({ post }) {
return <article>{post.title}</article>;
}
// AFTER: app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPostSlugs();
return posts.map(p => ({ slug: p.slug }));
}
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return <article>{post.title}</article>;
}
// For ISR, configure in the fetch call or route segment:
export const revalidate = 3600;
getServerSideProps (Server-Side Rendering)
// BEFORE: pages/dashboard/index.tsx
export async function getServerSideProps(context) {
const session = await getSession(context);
if (!session) {
return { redirect: { destination: '/login', permanent: false } };
}
const data = await getDashboardData(session.user.id);
return { props: { data } };
}
export default function Dashboard({ data }) {
return <DashboardContent data={data} />;
}
// AFTER: app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
export const dynamic = 'force-dynamic'; // Equivalent to SSR
export default async function Dashboard() {
const session = await getServerSession();
if (!session) redirect('/login');
const data = await getDashboardData(session.user.id);
return <DashboardContent data={data} />;
}
Notice how much simpler the App Router version is. No special exported function, no props threading, no context object. The component itself is async and fetches what it needs directly. Redirects use the redirect() function instead of returning objects.
Client-Side Data Fetching (useEffect / SWR / React Query)
Client-side fetching patterns remain the same, but they belong in Client Components. This is an important nuance: if a page needs both server-fetched data and client-side interactivity, split it.
// app/projects/page.tsx (Server Component)
import { ProjectFilters } from './ProjectFilters'; // Client Component
export default async function ProjectsPage() {
const projects = await getProjects(); // Server fetch
const categories = await getCategories(); // Server fetch
return (
<div>
<h1>Projects</h1>
<ProjectFilters
initialProjects={projects}
categories={categories}
/>
</div>
);
}
// app/projects/ProjectFilters.tsx
'use client';
import { useState } from 'react';
export function ProjectFilters({ initialProjects, categories }) {
const [filter, setFilter] = useState('all');
const filtered = initialProjects.filter(p =>
filter === 'all' || p.category === filter
);
// ... interactive filtering UI
}
Phase 4: API Routes to Route Handlers
// BEFORE: pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
const users = await db.user.findMany();
return res.status(200).json(users);
}
if (req.method === 'POST') {
const user = await db.user.create({ data: req.body });
return res.status(201).json(user);
}
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end();
}
// AFTER: app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
Route Handlers use standard Web API Request/Response objects instead of the custom NextApiRequest/NextApiResponse. Each HTTP method is a named export. No switch/if chains. This also means Route Handlers work at the edge — they’re compatible with Vercel Edge Functions and other edge runtimes.
Phase 5: Metadata and SEO
// BEFORE: pages/blog/[slug].tsx (using next/head)
import Head from 'next/head';
export default function BlogPost({ post }) {
return (
<>
<Head>
<title>{post.title} | Harbor Software</title>
<meta name="description" content={post.excerpt} />
<meta property="og:title" content={post.title} />
<meta property="og:image" content={post.coverImage} />
</Head>
<article>{/* content */}</article>
</>
);
}
// AFTER: app/blog/[slug]/page.tsx (using Metadata API)
import type { Metadata } from 'next';
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
return {
title: `${post.title} | Harbor Software`,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
}
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
return <article>{/* content */}</article>;
}
The Metadata API is type-safe, composable (child layouts merge with parent metadata), and supports dynamic generation via the generateMetadata async function. No more importing Head into every component.
Phase 6: Error Handling and Loading States
The App Router introduces file-convention-based error and loading boundaries:
app/
dashboard/
page.tsx
loading.tsx # Shows while page.tsx is streaming
error.tsx # Catches errors in this segment
not-found.tsx # Custom 404 for this segment
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="grid grid-cols-3 gap-6">
{[1, 2, 3].map(i => (
<div key={i} className="h-32 bg-gray-200 animate-pulse rounded-lg" />
))}
</div>
);
}
// app/dashboard/error.tsx
'use client'; // Error boundaries must be client components
export default function DashboardError({ error, reset }) {
return (
<div className="p-6 bg-red-50 rounded-lg">
<h2 className="text-red-800 font-bold">Something went wrong</h2>
<p className="text-red-600">{error.message}</p>
<button onClick={() => reset()} className="mt-4 px-4 py-2 bg-red-600 text-white rounded">
Try again
</button>
</div>
);
}
This is vastly superior to the Pages Router, where error handling meant wrapping components in ErrorBoundary manually or using _error.tsx globally. Now each route segment gets its own error and loading boundary automatically.
Phase 7: Authentication Patterns
Authentication in the App Router works differently because middleware and Server Components can both check auth status before rendering:
// middleware.ts (unchanged from Pages Router)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('session');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
};
// app/dashboard/layout.tsx (additional server-side check)
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function DashboardLayout({ children }) {
const session = await getSession();
if (!session) redirect('/login');
return (
<div className="flex">
<Sidebar user={session.user} />
<main className="flex-1">{children}</main>
</div>
);
}
The Incremental Migration Strategy
You don’t have to migrate everything at once. The Pages Router and App Router coexist. Here’s the order we recommend based on our own migration experience:
- Create
app/layout.tsx— the root layout. Move your global providers, fonts, and analytics here. - Migrate static pages first (about, contact, terms). These are the simplest — no data fetching, no interactivity.
- Migrate data-heavy pages next (blog, products, listings). These benefit the most from async Server Components.
- Migrate interactive pages last (dashboard, forms, settings). These require careful client/server boundary planning.
- Migrate API routes last. They work fine in
pages/api/alongside the App Router. Only migrate when you need edge runtime or the cleaner Route Handler syntax.
Gotchas We Hit in Production
- Dynamic params are now a Promise. In Next.js 15,
paramsandsearchParamsare async. You mustawait paramsbefore accessing properties. This broke our entire test suite initially. - useRouter changed.
next/routerbecomesnext/navigation. The newuseRouterdoesn’t havequery— useuseSearchParams()anduseParams()separately. - Cookies and headers are server-only. The
cookies()andheaders()functions fromnext/headersonly work in Server Components and Route Handlers, not in Client Components. - Default caching is aggressive. In Next.js 14, fetch requests are cached by default. This surprised us when dashboard data wasn’t updating. Use
cache: 'no-store'orexport const dynamic = 'force-dynamic'for real-time data.
Was It Worth It?
For Harbor Software, unequivocally yes. Our project management tool loads 40% faster, the codebase is 15% smaller (eliminated getServerSideProps boilerplate across 47 routes), and new developers onboard faster because the data fetching model is simpler — fetch where you need it, no prop drilling from page-level functions.
The migration itself was painful. Three weeks for a medium-sized app. But the App Router is where Next.js is investing all its energy — new features (Server Actions, Parallel Routes, Intercepting Routes) only land in the App Router. Staying on Pages Router means falling behind.
Migrate incrementally, test thoroughly, and lean into the Server Component model rather than fighting it. The patterns feel strange at first, but once they click, you won’t want to go back.