Next.js Pages to App Router Migration Gotchas
Developer Guide

Next.js Pages to App Router Migration Gotchas

Migrating Pages Router to App Router? Avoid cookies timing bugs, router.events removal, metadata conflicts, client boundary creep, and auth middleware breaks.

2026-06-17
11 min read
Next.js Pages to App Router Migration Gotchas

Introduction#

Migrating from Pages Router to App Router breaks production when you treat it as a file move. The dangerous parts are cookies, router events, metadata ownership, client boundaries, and middleware running on the Edge Runtime.

For the broader App Router model, read Next.js App Router complete guide, Next.js App Router folder structure at scale, and Next.js 15 cookies async error fix.

Real Reports This Guide Is Based On#

Gotcha 1: getServerSideProps Becomes Render-Time Code#

In Pages Router, getServerSideProps gave you a request-shaped mental model:

ts
export async function getServerSideProps(ctx) {
  const token = ctx.req.cookies.token;
  return { props: { token } };
}

In App Router, server components read request data through dynamic APIs:

tsx
import { cookies } from "next/headers";
 
export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;
 
  return <Dashboard token={token} />;
}

The gotcha is timing. If middleware sets a cookie on the response, the same render may not see it in the way your old getServerSideProps code did. Pages Router and App Router do not expose cookies through the same object, and mixed apps can behave differently.

Use redirects after setting auth cookies:

ts
import { NextResponse } from "next/server";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const response = NextResponse.redirect(new URL("/dashboard", url.origin));
 
  response.cookies.set("session", "value", {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
  });
 
  return response;
}

Then read cookies on the next request. Do not depend on a cookie set during middleware/proxy being visible everywhere in the same request path.

Gotcha 2: next/router Is Not next/navigation#

Pages Router code often used router.events for progress bars, analytics, and monitoring:

ts
import Router from "next/router";
 
Router.events.on("routeChangeStart", () => progress.start());
Router.events.on("routeChangeComplete", () => progress.done());

App Router does not provide router.events in next/navigation. If your migration deletes _app.tsx and moves the shell into app/layout.tsx, your monitoring can silently disappear.

Replace event listeners with pathname/search param effects:

tsx
"use client";
 
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
 
export function NavigationReporter() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
 
  useEffect(() => {
    const query = searchParams.toString();
    analytics.page(query ? `${pathname}?${query}` : pathname);
  }, [pathname, searchParams]);
 
  return null;
}

This is not a perfect replacement for "route start" events. It reports committed navigation, not the beginning of a transition. If you need loading UI, use loading.tsx, Suspense boundaries, or explicit transitions.

Don't
import Router from 'next/router' inside an App Router component
Do
use usePathname(), useSearchParams(), loading.tsx, and Suspense

Gotcha 3: Metadata Conflicts With Legacy Head Code#

Pages Router apps often have SEO split across _document.tsx, _app.tsx, next/head, and page components. App Router wants metadata in metadata exports or generateMetadata.

Do not keep critical page metadata only in legacy files after moving a route to app/.

tsx
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }) {
  const product = await getProduct(params.slug);
 
  return {
    title: product.title,
    description: product.description,
    alternates: {
      canonical: `/products/${product.slug}`,
    },
  };
}

Also check _document.tsx leftovers. _document still belongs to Pages Router and document shell concerns. It is not where App Router route metadata lives.

The production symptom is subtle: the app builds, routes render, but canonical tags, OG tags, or robots values are wrong. That hurts SEO more than it hurts local development.

Gotcha 4: Client Component Boundary Creep#

App Router gives you server components by default. The migration gets messy when one interactive component pulls an entire route into the client.

This is the bad pattern:

tsx
"use client";
 
import { getInvoices } from "@/lib/db";
 
export default function BillingPage() {
  // This server utility now leaks into a client bundle.
}

Split the server data fetch from the client interaction:

tsx
// app/billing/page.tsx
import { getInvoices } from "@/lib/db";
import { BillingClient } from "./BillingClient";
 
export default async function BillingPage() {
  const invoices = await getInvoices();
  return <BillingClient invoices={invoices} />;
}
tsx
// app/billing/BillingClient.tsx
"use client";
 
export function BillingClient({ invoices }) {
  return <InvoiceTable invoices={invoices} />;
}

The rule is simple: fetch and authorize on the server, interact on the client. If you import database code, secrets, Node APIs, or server-only helpers from a client component, the migration is going sideways.

Gotcha 5: Middleware Auth Breaks On Vercel Edge#

Pages Router auth often lived in API routes, getServerSideProps, or Node middleware-like wrappers. App Router migrations tend to move more logic into middleware.ts or proxy.ts.

That file runs on the Edge Runtime. Do not import Node-only adapters there.

ts
// Bad in middleware
import { PrismaAdapter } from "@auth/prisma-adapter";
import { Redis } from "ioredis";

Do only Edge-safe checks in middleware:

ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
 
export function middleware(request: NextRequest) {
  const hasSession = request.cookies.has("session");
 
  if (!hasSession && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*"],
};

Then perform database-backed authorization in a route handler or server component. For production matcher debugging, use Next.js middleware not running on Vercel.

Gotcha 6: Mixed Routing Is A Temporary State, Not A Strategy#

Next.js lets pages/ and app/ coexist. That is useful for migration, but it creates strange edges around i18n, pageExtensions, API routes, catch-all routes, and cookies.

Use a migration map:

txt
pages/account.tsx              -> app/account/page.tsx
pages/settings/billing.tsx     -> app/settings/billing/page.tsx
pages/api/stripe/webhook.ts    -> keep until route handler is tested
pages/_document.tsx            -> audit, do not copy blindly

Move one route family at a time. Do not migrate your auth callback, dashboard shell, API routes, and i18n routing in the same pull request. If production breaks, you will not know which layer caused it.

Gotcha 7: API Routes And Route Handlers Have Different Shapes#

Pages Router API routes use req and res:

ts
export default async function handler(req, res) {
  if (req.method !== "POST") {
    return res.status(405).json({ error: "Method not allowed" });
  }
 
  const body = req.body;
  return res.status(200).json({ ok: true, body });
}

App Router route handlers use Web Request and Response objects:

ts
export async function POST(request: Request) {
  const body = await request.json();
 
  return Response.json({ ok: true, body });
}

That means no req.query, no res.status().json(), and no automatic mental carry-over from Express-style code. Search params come from the URL:

ts
export async function GET(request: Request) {
  const url = new URL(request.url);
  const page = url.searchParams.get("page") ?? "1";
 
  return Response.json({ page });
}

For webhooks, be extra careful. Many providers require the raw request body for signature verification. In a route handler, read request.text() once and verify that exact string. Do not call request.json() first and expect to reconstruct the original body.

ts
export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  const rawBody = await request.text();
 
  // verify rawBody with signature here
 
  return Response.json({ received: true });
}

If you are migrating Stripe or GitHub webhooks, do that in a separate PR from page routing. A webhook can pass build and still fail only when the provider sends a real signed request.

Gotcha 8: Caching Replaces Several Old Data-Fetching Habits#

Pages Router made the data mode explicit in function names: getServerSideProps, getStaticProps, and getStaticPaths. App Router spreads that decision across fetch options, route segment config, generateStaticParams, and dynamic APIs like cookies().

This page is dynamic:

tsx
export const dynamic = "force-dynamic";

This fetch is uncached:

tsx
await fetch("https://api.example.com/account", {
  cache: "no-store",
});

This fetch can revalidate:

tsx
await fetch("https://api.example.com/posts", {
  next: { revalidate: 300 },
});

The migration bug is assuming that a page which used getServerSideProps will remain dynamic after you move the code into a server component. If the route stops reading cookies, headers, search params, or uncached fetches, it may become more static than you intended.

For authenticated dashboards, be explicit. For public marketing pages, embrace caching. For mixed pages, split the dynamic user-specific piece into a smaller server component or route.

Write the intended cache mode in the migration checklist before moving the file. That one note prevents the classic "it worked in Pages Router" debate later.

Also record who owns invalidation: the mutation, the webhook, or the scheduled job. Ambiguous ownership is how stale production pages survive code review.

Name the owner in the pull request description too.

When this won't work
  • If your app depends on router.events start/complete timings, there is no one-line App Router replacement.
  • If middleware imports Node-only auth adapters, it can fail only after deploy.
  • If you keep i18n config from Pages Router while moving localized routes to app/[lang], test every canonical URL.

Summary#

  • Treat cookies() as a dynamic request API, not a getServerSideProps clone.
  • Replace router.events with committed-navigation effects and App Router loading UI.
  • Move SEO into metadata and generateMetadata for migrated routes.
  • Keep server utilities out of client components.
  • Keep Edge middleware thin and push database authorization to server runtime code.
  • Migrate webhooks and cache-sensitive routes separately from UI route moves.

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.