Migrate Next.js Pages Router to App Router 2026
TL;DR
Migrate incrementally — App Router and Pages Router coexist in the same Next.js app. Start by moving the root layout, then migrate one page at a time. The biggest changes: data fetching (no getServerSideProps → Server Components with async), auth (session in cookies() vs getServerSideProps context), and layouts (no _app.tsx → layout.tsx with nested providers).
Key Takeaways
- Incremental migration: Pages Router pages still work while you migrate to App Router
- Server Components:
async function Page()replacesgetServerSideProps - Client Components: Add
'use client'only when needed (event handlers, hooks, state) - Auth migration:
getSession(req)→auth()from Auth.js/Clerk in Server Components - Layouts: Nested
layout.tsxfiles replace global_app.tsx+ per-page layout pattern
Why Migrate Now?
Most SaaS boilerplates launched before 2023 used the Pages Router. In 2026, the App Router is the standard — it ships with better caching, streaming/Suspense support, server-side components that dramatically reduce client bundle size, and nested layouts that eliminate a lot of prop-drilling.
Practically, if your boilerplate is Pages Router-based, you're missing out on:
- Smaller client bundles: Data fetching components are server-side by default — no JavaScript shipped to the browser for those components
- Nested layouts: Route groups with shared layouts without the
_app.tsxworkaround - Server Actions: Form mutations without separate API routes
- Parallel routes and intercepting routes: Complex UX patterns (modals, drawers, side-by-side views) that were painful in Pages Router
The migration is straightforward for simple SaaS apps. The complexity scales with: number of pages, complexity of auth setup, use of context providers that wrap the whole app, and patterns like getStaticProps with ISR.
When to Migrate vs When to Wait
Migrate now if:
- App has fewer than 30 pages
- Auth is NextAuth (Auth.js) — the migration path is well-documented
- No complex
getStaticPropswith ISR patterns - Your team can allocate 1–2 focused sprints
Wait to migrate if:
- Your app has 100+ pages and a migration would take weeks of dedicated time
- You're mid-feature and can't afford the context switch
- You're using Pages Router-specific features (custom
_document.tsx, complexgetStaticPaths) that don't have a clean App Router equivalent
For most early-stage SaaS apps, the migration is worth doing before the codebase gets large.
Step 1: Create App Directory Structure
src/
├── app/ ← New App Router
│ ├── layout.tsx ← Root layout (replaces _app.tsx)
│ ├── (auth)/ ← Route group (no URL segment)
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── signup/
│ │ └── page.tsx
│ ├── (dashboard)/ ← Protected route group
│ │ ├── layout.tsx ← Dashboard layout with sidebar
│ │ └── dashboard/
│ │ └── page.tsx
│ └── api/ ← API routes
│ └── ...
├── pages/ ← Old Pages Router (still works!)
│ ├── old-page.tsx ← Pages Router pages still render
│ └── api/
│ └── legacy-api.ts
The coexistence of app/ and pages/ is not just a temporary workaround — Next.js officially supports running both simultaneously. You can migrate page by page, deploying at each step, with no big-bang migration required.
Step 2: Root Layout (replaces _app.tsx)
The biggest conceptual shift: _app.tsx handled global providers and shared layout. In App Router, this is split into app/layout.tsx (root layout) and nested layout.tsx files per route group.
// app/layout.tsx:
import { Inter } from 'next/font/google';
import { ThemeProvider } from '@/components/theme-provider';
import { Toaster } from '@/components/ui/toaster';
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
Important: All context providers (ThemeProvider, ReactQueryClientProvider, ClerkProvider, etc.) must be Client Components. If they aren't already, wrap them in a Client Component:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// Then in app/layout.tsx:
import { Providers } from './providers';
// ... wrap {children} with <Providers>
Step 3: Migrate Data Fetching
This is the most impactful change — getServerSideProps is replaced by async Server Components. The result is significantly less code.
// BEFORE — Pages Router getServerSideProps:
export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getSession(context);
if (!session) return { redirect: { destination: '/login', permanent: false } };
const user = await db.user.findUnique({ where: { id: session.user.id } });
const projects = await db.project.findMany({ where: { userId: session.user.id } });
return {
props: {
user: JSON.parse(JSON.stringify(user)), // serialize dates
projects: JSON.parse(JSON.stringify(projects)),
},
};
}
export default function DashboardPage({ user, projects }: Props) {
return <Dashboard user={user} projects={projects} />;
}
// AFTER — App Router Server Component:
import { auth } from '@/lib/auth'; // Auth.js or Clerk
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect('/login');
// Direct DB access in component — no getServerSideProps:
const [user, projects] = await Promise.all([
db.user.findUnique({ where: { id: session.user.id } }),
db.project.findMany({ where: { userId: session.user.id } }),
]);
return <Dashboard user={user!} projects={projects} />;
}
Key improvements: no date serialization hacks, parallel data fetching with Promise.all, auth check inline, no prop threading.
Step 4: Auth Migration
Auth.js (formerly NextAuth.js) v5 changed its API significantly for App Router. If you're on NextAuth v4 (Pages Router era), the migration path is:
// BEFORE — Pages Router middleware:
import { withAuth } from 'next-auth/middleware';
export default withAuth({
pages: { signIn: '/login' },
});
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
// AFTER — App Router middleware (Auth.js v5):
import { auth } from '@/lib/auth';
import { NextRequest } from 'next/server';
export default auth((req) => {
if (!req.auth) {
return Response.redirect(new URL('/login', req.url));
}
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|login|signup).*)'],
};
For Clerk users, the migration is simpler — Clerk has first-class App Router support with clerkMiddleware and the auth() helper for Server Components.
Step 5: Client Components
The default in App Router is Server Components. Only add 'use client' when you actually need client-side features: event handlers, hooks, browser APIs, or third-party libraries that require a browser context.
// Server Component (default — no 'use client'):
// Can fetch data, access server resources, no hooks
export default async function ProductList() {
const products = await db.product.findMany();
return (
<ul>
{products.map(p => (
<ProductItem key={p.id} product={p} />
))}
</ul>
);
}
// Client Component (only when needed):
'use client';
import { useState } from 'react';
// ProductItem needs onClick — must be client:
export function ProductItem({ product }: { product: Product }) {
const [expanded, setExpanded] = useState(false);
return (
<li onClick={() => setExpanded(!expanded)}>
{product.name}
{expanded && <p>{product.description}</p>}
</li>
);
}
The pattern is: push 'use client' as far down the component tree as possible. A page component that fetches data should be a Server Component; the interactive parts (buttons, dropdowns, forms) should be Client Components that receive data as props.
Common Migration Gotchas
1. Context providers must be Client Components. ThemeProvider, SessionProvider, QueryClientProvider — all require 'use client'. Wrap them in a single Providers client component as shown above.
2. No serialize/deserialize needed. In Pages Router, you had to JSON.parse(JSON.stringify(data)) to serialize dates and non-plain objects. In App Router, you pass DB objects directly as props from Server Components.
3. Cookies and headers in App Router. Use import { cookies, headers } from 'next/headers' in Server Components. This doesn't work in Client Components.
4. Dynamic routes change shape. Pages Router: pages/posts/[id].tsx. App Router: app/posts/[id]/page.tsx. The params are now a prop on the page component.
5. API routes stay mostly the same. You can keep them in pages/api/ — both work. But if you want them to access Server Component patterns (cookies(), etc.), move them to app/api/.
6. getStaticProps / ISR changes. Static generation moves to export const revalidate = 3600 at the segment level and fetch with cache options: fetch(url, { next: { revalidate: 3600 } }).
Incremental Migration Checklist
Week 1: Foundation
- Create
app/directory - Move
_app.tsxlogic toapp/layout.tsx - Wrap providers in a Client Component
- Keep all
pages/files — they still work - Deploy and verify nothing broke
Week 2: High-Traffic Pages
- Migrate dashboard/home page first
- Convert
getServerSideProps→ async Server Components - Update auth middleware to Auth.js v5 / Clerk pattern
- Test auth flows thoroughly
Week 3: Remaining Pages
- Migrate remaining pages one by one
- Move API routes to
app/api/if needed - Remove
pages/directory when all pages are migrated
Performance Gains from App Router Migration
Beyond the developer experience improvements, the App Router delivers measurable performance benefits that matter for user-facing metrics.
Reduced JavaScript bundle size. Server Components render on the server and send HTML — no React component code shipped to the browser. In a typical Pages Router app, every page component and its dependencies contribute to the client bundle. After migration, components that only render data (no state, no events) are zero-weight on the client. For data-heavy pages (dashboards, reports), this can reduce the initial JS load by 30–60%.
Streaming and Suspense. App Router pages stream HTML from the server, which means users see content earlier even for slow database queries. Wrap slow sections in <Suspense fallback={<Skeleton />}> and the rest of the page renders while the slow section loads. This significantly improves perceived performance, even if raw data fetching time is the same.
Better caching control. fetch in Server Components participates in Next.js's request deduplication — if two components on the same page fetch the same URL, it's one network request. And unstable_cache lets you control server-side data caching at a granular level, reducing database query counts per request.
Reduced API route overhead. Many Pages Router apps have API routes that exist solely for server-side data fetching (because you couldn't access the database directly in pages). With Server Components, these disappear — the component fetches data directly, eliminating a round-trip.
Track Core Web Vitals before and after migration. Most teams see improvement in LCP (Largest Contentful Paint) and INP (Interaction to Next Paint) after a thorough App Router migration.
Handling the Migration in Production
For production apps with active users, the migration needs a careful approach:
Feature flags for migrated pages. As you migrate pages, use a feature flag to slowly roll out the new App Router version. If a migrated page has a bug, roll back the flag without a deployment.
Canary deployments. Deploy the App Router pages alongside the Pages Router pages. Route 5% of traffic to the App Router version, monitor error rates and performance, then increase the rollout percentage.
Monitor the auth flows most carefully. Auth bugs after App Router migration are the most common and most damaging failure. After migrating each auth-related page (login, signup, password reset, session handling), run manual QA on every flow before releasing.
Keep a rollback plan. If you remove the pages/ directory and the migration has a critical bug, your rollback requires reverting to a previous deployment. Consider keeping pages/ around until you've run the App Router version in production for 2–4 weeks with no issues.
Related Resources
For boilerplates that already use the App Router, see best boilerplates for AI SaaS products — all modern AI boilerplates ship with App Router by default. If you're customizing an existing ShipFast installation (which recently moved to App Router), see the ShipFast customization guide for App Router-specific patterns. For adding authentication to App Router from scratch, the Auth.js v5 vs Lucia v3 vs Better Auth comparison covers the options.
Methodology
Migration patterns based on the official Next.js migration guide and community reports from Discord and GitHub discussions. Auth.js v5 migration steps from the Auth.js v5 beta documentation. Tested against Next.js 14.2 and 15.x. The App Router migration path has stabilized significantly since early adoption — teams migrating in 2026 benefit from substantially better documentation and fewer breaking edge cases than those who migrated in 2023.
Find modern App Router boilerplates at StarterPick.