Table of Contents
You can migrate a Next.js app from the Pages Router to the App Router one route at a time, because both routers run side by side in the same project. Move shared layout into app/layout.tsx, rebuild each page as a Server Component that fetches its own data, then replace next/head with the Metadata API and API routes with Route Handlers. Only migrate when the App Router gives you something you need, and never as a single big-bang rewrite.
If you are still on plain React, start with our guide to migrating from React to Next.js and come back here afterwards.
Table of Contents
Should you migrate at all?
The Pages Router is still supported, and a stable Pages Router app is not an emergency. Weigh the move against real problems you have today.
Good reasons to migrate
- Large client bundles. Server Components render on the server and send no component JavaScript to the browser, which helps content-heavy pages.
- Nested layouts. Layouts that persist between navigations (dashboards, settings areas, docs sidebars) are native in the App Router and awkward in
_app. - Data fetching spread across a page. Each component can fetch what it needs instead of drilling props down from one
getServerSideProps. - Streaming.
loading.tsxandSuspenselet slow sections stream in without blocking the whole page. - Server Actions. Forms and mutations can call server code directly, removing a lot of small API routes.
When not to migrate (yet)
- The app is stable, rarely changed and nearing the end of its life.
- Critical dependencies (UI kit, CSS-in-JS library, auth, analytics) do not yet work with Server Components and you cannot replace them.
- Your team has no time to learn the new caching and rendering model.
- The app is almost entirely client-side (a logged-in SPA behind auth) and gains little from server rendering.
Incremental migration: both routers can coexist
Adding an app/ directory does not break pages/. Next.js serves routes from both, so you can move one route, ship it, and move the next. Three rules keep this safe:
- No duplicate routes. The same URL cannot be defined in both directories; the build fails on the conflict. Delete the
pages/file in the same commit that adds theapp/route. - Crossing routers is a full page load. Client-side navigation works within each router, but a link from a Pages route to an App route (or back) triggers a hard navigation. Migrate groups of routes that users move between often, such as a whole section, together.
- Shared components need care. A component that uses
next/routerwill not work inapp/. Keep shared UI free of router imports, or pass values in as props.
_app and _document become the root layout
The App Router has no _app or _document. Their jobs move into app/layout.tsx, which must render the <html> and <body> tags. Global CSS, fonts via next/font and app-wide providers go here.
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Providers from './providers';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: { default: 'Acme', template: '%s | Acme' },
description: 'Acme product catalogue',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
While pages/ still exists, keep _app and _document too. They continue to wrap the Pages routes, and the root layout wraps the App routes. Shared concerns such as analytics live in both until the migration ends.
Mapping data fetching to Server Components
This is the core of the migration. In the App Router, pages and layouts are Server Components by default and can be async, so you fetch data directly in the component. The special data functions disappear.
getServerSideProps → a dynamic Server Component
Fetch inside the component and opt out of caching. Reading request data with cookies() or headers() also makes a route dynamic.
// app/orders/page.tsx
import { cookies } from 'next/headers';
export default async function OrdersPage() {
const token = (await cookies()).get('session')?.value;
const res = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
const orders: Order[] = await res.json();
return <OrderTable orders={orders} />;
}
getStaticProps with revalidate → cached fetch or a route segment option
Incremental Static Regeneration becomes a revalidate value, set either per request or for the whole route. On-demand revalidation (what res.revalidate() did in an API route) becomes revalidatePath() or revalidateTag(), called from a Route Handler or Server Action.
// Per request: cache and refresh at most once an hour
const res = await fetch('https://cms.example.com/api/posts', {
next: { revalidate: 3600, tags: ['posts'] },
});
// Or for every fetch in the route segment
export const revalidate = 3600;
getStaticPaths → generateStaticParams
generateStaticParams returns the params to pre-render at build time. The fallback option maps to dynamicParams: leave it as the default true to render unknown paths on demand, or set it to false to return a 404 for them. notFound: true becomes a call to notFound(), and redirect becomes redirect(), both from next/navigation.
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export const dynamicParams = true; // like fallback: 'blocking'
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <Article post={post} />;
}
Recent Next.js versions pass params and searchParams as Promises, as above; older App Router versions passed plain objects. Check the version you are on before copying examples. If you are unsure which rendering strategy a route needs, our comparison of SSG and SSR in Next.js for SEO covers the trade-offs.
Be explicit about caching
The default caching behaviour of fetch and routes has changed between major versions, and it is the most common source of surprises. Do not rely on defaults. For every route, decide whether it is static, time-revalidated or fully dynamic, and state it with cache, next.revalidate or a route segment option. Data that does not come from fetch (an ORM or SDK call) is not cached by the fetch cache at all; wrap it with React’s cache() to deduplicate calls within one request, and use the framework’s caching APIs if you need it cached across requests.
next/head → the Metadata API
next/head does not work in app/. Export a static metadata object, or an async generateMetadata function that receives the same params as the page when the title depends on data. Canonical URLs, Open Graph and robots all have typed fields, and layout metadata merges with page metadata.
A fetch that generateMetadata and the page both make is memoised within the request, so you do not pay for it twice. Before you switch a high-traffic route, diff the rendered <head> of the old and new versions; losing a canonical tag or a robots directive is an easy SEO regression.
API routes → Route Handlers
pages/api/* keeps working, so API routes can be moved last. When you do move them, a Route Handler is a route.ts file that exports one function per HTTP method and uses the standard Web Request and Response objects instead of Node-style req and res.
// app/api/newsletter/route.ts
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email } = await request.json();
if (!email) {
return NextResponse.json({ error: 'Email required' }, { status: 400 });
}
await subscribe(email);
return NextResponse.json({ ok: true });
}
Before porting an endpoint, ask whether it still needs to exist. Many internal API routes only served your own forms and can become Server Actions. Keep Route Handlers for webhooks, mobile apps and other external consumers.
Client components and ‘use client’ boundaries
Every Pages Router component was a client component. In the App Router, anything that uses state, effects, event handlers or browser APIs needs the 'use client' directive at the top of its file. That directive marks a boundary: the file and everything it imports ship to the browser.
- Push the boundary down. Keep pages and layouts as Server Components and mark only the interactive leaves (a filter panel, a cart button) as client components.
- Pass serialisable props. Props crossing from server to client must be serialisable: no functions, class instances or database clients.
- Use children to nest server inside client. A client component cannot import a Server Component, but it can render one passed in as
children. - Swap the router hooks.
next/routerbecomesnext/navigation:useRouter,usePathnameanduseSearchParams.router.queryandrouter.eventsare gone; read params from props or hooks instead.
Data-fetching libraries, auth and middleware
React Query and SWR still work, but only inside client components. Use them where you need client-side cache, polling or optimistic updates, and let Server Components handle initial page data. TanStack Query supports prefetching on the server and hydrating the client cache if you want both.
Authentication should be checked close to the data, not only in the UI. Read the session in Server Components, Route Handlers and Server Actions via cookies(), and re-check it in every Server Action, because each action is a callable endpoint. Confirm your auth library has App Router support before you start; most major ones do, but the setup differs from the Pages version.
Middleware runs before both routers, so your existing redirects, rewrites and auth gates keep working during the migration. Newer Next.js releases rename the middleware.ts file convention to proxy.ts, so check the docs for your version. Treat it as a fast first check, not your only one.
Common pitfalls
Context providers in the root layout
Context does not work in Server Components, so a root layout cannot render ThemeProvider or QueryClientProvider directly if they are not already marked as client components. Wrap them in one client component and render that from the layout.
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new QueryClient());
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
Third-party libraries that assume the client
Carousels, charts, maps and older UI kits often use hooks without a 'use client' directive, so they throw when imported into a Server Component. Re-export them from a small file that starts with 'use client'. Runtime CSS-in-JS libraries need a style registry set up in the root layout, and some do not support Server Components at all; check this early, because it can decide your timeline.
Caching surprises
Typical symptoms: stale data after a CMS update, or a route you expected to be static rendering on every request because something deep in the tree read cookies(). Check the build output, which marks each route as static or dynamic, and test revalidation against a staging CMS before launch.
Leaking server code to the client
A utility that reads a secret environment variable can end up in a client bundle if a client component imports it. Add import 'server-only' to modules that must never reach the browser; the build then fails instead of leaking.
A step-by-step migration plan
- Upgrade first. Move to a current Next.js and React version while still on the Pages Router, fix deprecations and ship that on its own.
- Audit dependencies. List every library that touches rendering, styling, auth or routing and confirm its App Router support.
- Add the root layout. Create
app/layout.tsxwith fonts, global CSS and a clientProviderswrapper. - Migrate a low-risk route. Pick a simple, mostly static page (About, a legal page) to prove the setup, metadata and deployment.
- Move sections, not single pages. Migrate groups of related routes together to avoid hard navigations between routers.
- Convert data fetching. Replace
getServerSideProps,getStaticPropsandgetStaticPaths, setting caching explicitly for each route. - Port metadata and check SEO. Compare titles, canonicals, structured data and status codes against the old pages.
- Replace API routes. Turn internal ones into Server Actions and external ones into Route Handlers.
- Remove the Pages Router. Delete
pages/,_appand_documentonce nothing depends on them, and remove unused packages.
Ship after each step so regressions are easy to trace. Your deployment pipeline does not need to change; if you host on AWS, our step-by-step guide to deploying Next.js on AWS still applies.
Pages Router to App Router checklist
| Pages Router | App Router | Check |
pages/_app.tsx, pages/_document.tsx | app/layout.tsx with <html> and <body> | Providers wrapped in a client component |
getServerSideProps | Async Server Component, cache: 'no-store' | Route shows as dynamic in the build |
getStaticProps + revalidate | fetch with next.revalidate or export const revalidate | Content updates appear as expected |
getStaticPaths + fallback | generateStaticParams + dynamicParams | Unknown paths render or 404 correctly |
res.revalidate() | revalidatePath() / revalidateTag() | CMS webhooks still trigger updates |
next/head | metadata / generateMetadata | Titles, canonicals and robots unchanged |
pages/api/* | app/**/route.ts or Server Actions | Auth re-checked in each handler and action |
next/router | next/navigation hooks | No router.query or router.events left |
| Custom loading and error UI | loading.tsx, error.tsx, not-found.tsx | Correct HTTP status on error pages |
| Every component is a client component | Server by default, 'use client' where needed | Client bundle size compared before and after |
How VOCSO can help
VOCSO has been building software since 2009, and our Next.js development services team works on large, SEO-driven applications such as the Colleges18 college discovery portal, built on Next.js with a Strapi headless CMS. We can audit your Pages Router app, plan an incremental migration route by route, and protect your rankings along the way. If you need extra hands on an existing team, you can also hire Next.js developers from us under NDA from day one. Talk to us about your migration.
Frequently asked questions
Is the Pages Router deprecated?
No. It is still supported and receives fixes, but new features are designed around the App Router. New projects should start on the App Router.
Can I use the Pages Router and App Router in the same project?
Yes. Both directories can exist together, which is what makes incremental migration possible. The same route cannot be defined in both, and navigating between the two routers causes a full page load.
Will migrating to the App Router hurt my SEO?
It should not, provided URLs, metadata, canonical tags, structured data and status codes stay the same. Compare the rendered HTML of each route before and after, and keep redirects in place for any URL that changes.
Can I keep using React Query or SWR?
Yes, inside client components. Many teams move initial page data to Server Components and keep React Query or SWR for interactive, frequently refreshed data.
How long does a migration take?
It depends on the number of routes, how data fetching is organised and how many client-only dependencies you use. A dependency audit and one migrated section give a realistic estimate.
Related reading
- Next.js Performance: How to Pass Core Web Vitals (LCP, INP, CLS)
- Next.js SEO: A Technical Checklist for the App Router
- Static Site Generation (SSG) vs Server-Side Rendering (SSR) in NextJS: Which is Best for SEO?













Leave a Reply