next.js

Next.js App Router: A Practical Migration Guide

8 min
next.jsperformanceweb development

Next.js App Router: A Practical Migration Guide

March 16, 20268 min read
Next.js App Router: A Practical Migration Guide

Still on the Pages Router? Here's how to migrate to Next.js App Router incrementally — without breaking production or rewriting everything at once.

Most Next.js projects are still running on a router that's no longer the default

If your Next.js application was built before version 13, chances are it's using the Pages Router — the original architecture where every file in pages/ becomes a route. It works, it's familiar, and for thousands of production applications it's perfectly adequate.

But Vercel shipped the App Router as the default in Next.js 13.4, made it stable in 14, and has been clear since then: new development is happening there. Server Components, streaming, parallel routes, nested layouts, improved data fetching — the App Router isn't a cosmetic change. It's a fundamentally different mental model that unlocks performance and architectural patterns the Pages Router simply can't do.

We've migrated several client projects from Pages Router to App Router over the past year. Here's what actually works, what trips people up, and how to do it without breaking production.

Why migrate? What you actually gain

Before committing to a migration, it's worth being concrete about what you're getting.

React Server Components

This is the headline feature and the most significant shift. In the App Router, components are Server Components by default. They render on the server, ship zero JavaScript to the client, and can fetch data directly — no API endpoints required for most operations.

For a restaurant website fetching a menu from a CMS, this means the component that renders the menu list sends no JavaScript to the browser. It's HTML, generated server-side, from a direct database query. Pages that previously needed client-side fetching, loading spinners, and hydration can become static server renders — and visitors feel the difference immediately.

Nested layouts with persistent state

The Pages Router has a single _app.tsx for global layout. The App Router supports nested layouts at any level of the route hierarchy — and those layouts persist across navigation without re-rendering.

This is transformative for complex UIs. A dashboard with a persistent sidebar, a booking flow with a persistent navigation bar, an admin interface where the navigation never flashes on route change — all of this becomes straightforward with nested layouts. We rebuilt a hotel management dashboard using this pattern and eliminated a whole class of flickering bugs that had been plaguing the client for months.

Streaming and Suspense

The App Router supports React's Suspense API natively. You can stream parts of a page to the browser as they become ready, rather than waiting for every data fetch to complete before sending anything. For a page with slow third-party data — live availability, external review feeds, weather widgets — this means the fast parts of the page appear instantly while the slow parts load progressively. Users see content sooner, even if the total load time is unchanged.

Simplified data fetching

The Pages Router has getServerSideProps, getStaticProps, and getStaticPaths — three separate APIs with different rules, different caching behaviours, and different TypeScript signatures to memorise. The App Router replaces all of this with standard async/await in Server Components, with caching controlled via the fetch API's cache option or Next.js-specific unstable_cache for non-fetch data sources.

One mental model to apply consistently. No more remembering which data-fetching pattern to use and when.

Before you start: what you need to know

Migration is incremental by design — you don't need to rewrite everything at once. The Pages Router and App Router can coexist in the same project, which means you can migrate route by route over days, weeks, or months.

That said, a few things are worth understanding before you touch a file.

Client Components are still necessary — and that's fine

Server Components can't use useState, useEffect, event handlers, or browser APIs. Any component that needs interactivity must be marked 'use client' at the top of the file. This isn't a limitation — it's a design choice that forces you to be explicit about which code runs in the browser. The goal is to push as much as possible to the server and only opt into client-side JavaScript where you actually need it.

In practice, most applications end up with a relatively small surface area of Client Components: navigation toggles, form inputs, modals, interactive maps. The bulk of a content-driven site — the actual pages, articles, product listings — can be Server Components.

Context API requires a wrapper

The Context API doesn't work in Server Components. If your application uses React Context for global state (theme, auth, cart, locale), you'll need to wrap the consuming subtree in a Client Component marked with 'use client'. This is a common stumbling block for applications that use context extensively. The fix is usually a small wrapper component — but it's worth auditing your context usage before starting.

Middleware works the same, but test it carefully

middleware.ts in the project root works in both architectures, but if you're doing anything nuanced — reading cookies, setting response headers, redirecting based on auth state — test it thoroughly after migrating. Auth middleware in particular is worth stress-testing, since subtle changes in how cookies are read can cause hard-to-diagnose production issues.

The migration approach that actually works

We use an incremental, route-by-route strategy. Never attempt a full rewrite in one go.

Step 1: Create the app/ directory alongside pages/

Add an app/ directory at the project root. Create app/layout.tsx with your root layout:

``tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ); } ``

This is required — the App Router will throw without a root layout. Don't move anything else yet. Commit this file on its own and verify the build still passes.

Step 2: Migrate your simplest routes first

Start with pages that have no data fetching, no complex state, and no third-party dependencies. Static marketing pages, about pages, simple contact pages — move these first to build confidence with the new conventions before tackling the complex ones.

For each page, create the equivalent directory structure in app/. A page at pages/about.tsx becomes app/about/page.tsx. The component itself often needs minimal changes.

Step 3: Convert getStaticProps to async Server Components

Pages that use getStaticProps are the easiest to migrate. Instead of exporting a separate function, the data fetching moves directly into the component:

```tsx // Before (Pages Router) export async function getStaticProps() { const data = await fetchSomeData(); return { props: { data } }; } export default function Page({ data }) { ... }

// After (App Router) export default async function Page() { const data = await fetchSomeData(); return

...
; } ```

Add export const revalidate = 3600; to the file for ISR-style revalidation on a time interval, or export const dynamic = 'force-static'; for fully static generation.

Step 4: Handle client-only code

Any component using hooks, event handlers, or browser APIs needs the 'use client' directive. Add it to the top of the file and the component (and all components it imports) will run on the client.

Keep these components as small as possible — a pattern we use regularly is "thin client wrappers around server-rendered shells." The Server Component handles data fetching and renders the static markup; a small Client Component sits on top to handle interactivity. This keeps JavaScript bundles lean.

Step 5: Replace getServerSideProps with cache: 'no-store'

Pages using getServerSideProps (fully dynamic, rendered on every request) translate to Server Components with opted-out caching:

``tsx const res = await fetch('https://api.example.com/data', { cache: 'no-store' }); const data = await res.json(); ``

For non-fetch data sources like database queries, use unstable_cache with revalidate: 0:

```tsx import { unstable_cache } from 'next/cache';

const getData = unstable_cache( async () => { return await db.query(...); }, ['cache-key'], { revalidate: 0 } ); ```

Step 6: Remove migrated pages from pages/

Once a route is working correctly in app/, remove the equivalent file from pages/. Next.js gives priority to app/ routes when both exist, but removing the old file prevents confusion and potential conflicts down the line.

Common pitfalls and how to avoid them

Importing a Server Component into a Client Component. You can't do this directly — Server Components can only be composed into Client Components via children props. If you hit this error, restructure so the Client Component receives the Server Component output as a prop rather than importing it.

**Using useState without 'use client'.** You'll get a clear build error. Add the directive. If you're seeing it unexpectedly, check whether a library you're importing has implicit client-side dependencies — some older UI component libraries do.

**Forgetting that cookies() and headers() make routes dynamic.** Calling these functions anywhere in the component tree opts the entire route into dynamic rendering. This is correct behaviour, but it can be surprising if you expected a page to be statically generated.

Third-party component libraries without Server Component support. Some UI libraries — particularly older versions of Radix UI, certain animation libraries, and some charting packages — don't export Server Component-compatible builds. The fix is usually to wrap them in a 'use client' file. Check the library's documentation for its App Router compatibility status before starting a migration.

Losing route-level metadata. The Pages Router handled metadata via next/head. The App Router uses a generateMetadata export or a static metadata object. If you have complex SEO metadata, audit all your pages and ensure the metadata is correctly ported.

Key takeaways

  • Migration is incremental — Pages Router and App Router coexist. Start with the simplest routes and work up to the complex ones.
  • Server Components are the default — opt into 'use client' only where you need interactivity. Keep the client surface area as small as possible.
  • Data fetching simplifies significantlygetStaticProps, getServerSideProps, and getStaticPaths all have clean, simpler App Router equivalents.
  • Nested layouts are worth migrating for on their own — if layout persistence is causing bugs or UX issues, this feature alone can justify the investment.
  • Test middleware thoroughly — it's the most common source of subtle bugs during migration.
  • Migrate route by route, commit after each — never attempt a full cut-over in a single deployment. Incremental migration is safer and easier to debug.

Thinking about migrating but not sure where to start?

The App Router migration isn't difficult if you approach it methodically, but it does require a clear plan and the patience to do it properly. Rushed migrations — particularly on complex applications with heavy data fetching or a lot of third-party integrations — tend to introduce bugs that are painful to diagnose under production conditions.

We've handled this migration for client projects ranging from simple marketing sites to multi-tenant SaaS dashboards. If you're weighing up whether migration is worth it for your specific application, or you want a development team to handle it cleanly, get in touch — we're happy to give you an honest assessment of what it would involve.

Need help implementing this?

We build high-performance websites and automate workflows for ambitious brands. Let's talk about how we can help your business grow.