DEV Community

Cover image for Next.js Pages to App Router Migration Gotchas
Mahdi BEN RHOUMA
Mahdi BEN RHOUMA

Posted on Originally published at iloveblogs.blog

Next.js Pages to App Router Migration Gotchas

stack={[
{ name: "Next.js", version: "16.1" },
{ name: "React", version: "19" },
{ name: "Node", version: "22" }
]}
date="June 2026"
/>

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:

export async function getServerSideProps(ctx) {
  const token = ctx.req.cookies.token;
  return { props: { token } };
}
Enter fullscreen mode Exit fullscreen mode

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

import { cookies } from "next/headers";

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  return <Dashboard token={token} />;
}
Enter fullscreen mode Exit fullscreen mode

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:

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

import Router from "next/router";

Router.events.on("routeChangeStart", () => progress.start());
Router.events.on("routeChangeComplete", () => progress.done());
Enter fullscreen mode Exit fullscreen mode

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:

"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;
}
Enter fullscreen mode Exit fullscreen mode

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.

wrong="import Router from 'next/router' inside an App Router component"
right="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/.

// 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}`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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:

"use client";

import { getInvoices } from "@/lib/db";

export default function BillingPage() {
  // This server utility now leaks into a client bundle.
}
Enter fullscreen mode Exit fullscreen mode

Split the server data fetch from the client interaction:

// 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} />;
}
Enter fullscreen mode Exit fullscreen mode
// app/billing/BillingClient.tsx
"use client";

export function BillingClient({ invoices }) {
  return <InvoiceTable invoices={invoices} />;
}
Enter fullscreen mode Exit fullscreen mode

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.

// Bad in middleware
import { PrismaAdapter } from "@auth/prisma-adapter";
import { Redis } from "ioredis";
Enter fullscreen mode Exit fullscreen mode

Do only Edge-safe checks in middleware:

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*"],
};
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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 });
}
Enter fullscreen mode Exit fullscreen mode

App Router route handlers use Web Request and Response objects:

export async function POST(request: Request) {
  const body = await request.json();

  return Response.json({ ok: true, body });
}
Enter fullscreen mode Exit fullscreen mode

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:

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

  return Response.json({ page });
}
Enter fullscreen mode Exit fullscreen mode

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.

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 });
}
Enter fullscreen mode Exit fullscreen mode

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:

export const dynamic = "force-dynamic";
Enter fullscreen mode Exit fullscreen mode

This fetch is uncached:

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

This fetch can revalidate:

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

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.

  • 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.

Related


Originally published at https://www.iloveblogs.blog

Top comments (0)