Migrate Your SaaS from Pages Router to App Router 2026
·StarterPick Team
nextjsapp-routerpages-routermigrationsaas-boilerplate2026
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
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
Step 2: Root Layout (replaces _app.tsx)
// 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>
);
}
Step 3: Migrate Data Fetching
// 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)),
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} />;
}
Step 4: Auth Migration
// BEFORE — Pages Router middleware:
// middleware.ts at root (worked with Pages Router too, but now cleaner):
import { withAuth } from 'next-auth/middleware';
export default withAuth({
pages: { signIn: '/login' },
});
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*'],
};
// AFTER — App Router middleware (same pattern, but now:)
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).*)'],
};
Step 5: Client Components
// 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>
);
}
Common Migration Gotchas
1. Context providers must be Client Components:
- ThemeProvider, SessionProvider, QueryClientProvider
- Wrap at root layout level with 'use client'
2. No serialize/deserialize needed:
- App Router: pass DB objects directly as props
- Pages Router: had to JSON.parse(JSON.stringify(data))
3. Cookies and headers in App Router:
- import { cookies, headers } from 'next/headers'
- Only works in Server Components or Server Actions
4. Dynamic routes:
- Pages Router: pages/posts/[id].tsx
- App Router: app/posts/[id]/page.tsx
5. API routes stay the same:
- But move to app/api/ for Server Components access
- Or keep in pages/api/ — both work
Incremental Migration Checklist
Week 1:
[x] Create app/ directory
[x] Move _app.tsx logic to app/layout.tsx
[x] Keep all pages/ files — they still work
Week 2:
[ ] Migrate high-traffic pages first
[ ] Convert getServerSideProps → async Server Components
[ ] Update auth middleware
Week 3:
[ ] Migrate remaining pages
[ ] Move API routes to app/api/
[ ] Remove pages/ directory
Find modern App Router boilerplates at StarterPick.