Skip to main content

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.tsxlayout.tsx with nested providers).

Key Takeaways

  • Incremental migration: Pages Router pages still work while you migrate to App Router
  • Server Components: async function Page() replaces getServerSideProps
  • 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.tsx files 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.

Comments