Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 096 / 100

Migrating Pages Router to App Router in React & Next.js

An incremental path that works.

Expona Admin10 min read

After five years building production platforms across e-commerce, SaaS, and trading systems, I've watched Next.js evolve from a Pages Router framework to one built around the App Router. If you're running a mature application on Pages Router and wondering how to move forward, the good news is this: you don't have to migrate everything overnight. Migrating Pages Router to App Router can happen incrementally, with both routing systems coexisting in the same codebase until you're ready to flip the switch completely.

In this post, I'll walk you through a practical, battle-tested approach to this migration. We'll start with how to run both routers simultaneously, then move through layouts, data functions, and the client-side boundaries that catch most teams off guard. By the end, you'll have a clear roadmap for moving your application forward without breaking production.

Both Routers Coexist — Move Routes One at a Time

The single most important thing to understand is that Next.js allows pages/ and app/ to live in the same project. When a route exists in both directories, the App Router takes precedence. This means you can migrate routes gradually without any cutover risk.

Here's how I typically structure this in practice. Let's say you have a Pages Router application with routes like:

tsx
pages/
  index.tsx
  dashboard/index.tsx
  dashboard/settings.tsx
  api/
    health.ts
    users.ts

You create an app/ directory at the root and start migrating routes there:

tsx
app/
  page.tsx           // replaces pages/index.tsx
  dashboard/
    page.tsx         // replaces pages/dashboard/index.tsx
    settings/
      page.tsx       // replaces pages/dashboard/settings.tsx
  api/
    health/route.ts  // replaces pages/api/health.ts
    users/route.ts   // replaces pages/api/users.ts

pages/               // still here during migration
  index.tsx
  dashboard/index.tsx
  // ... etc

Start with lower-risk routes: API endpoints, public pages, or utility pages that don't have complex state management. Leave your highest-traffic, most-critical routes in Pages Router until you're confident in your migration process. I've seen teams migrate 70% of their routes over several sprints, then handle the remaining 30% in a focused effort.

One caveat: parallel route files (like pages/index.tsx and app/page.tsx) will conflict on rebuild. You'll see warnings in your terminal. This is expected and harmless during active migration, but it signals you should finish migrating that route. Don't leave conflicts lying around for weeks.

Start with Layouts — Recreate _app and _document as the Root Layout

Your _app.tsx and _document.tsx files are the entry point to your entire application in Pages Router. They handle global providers, styling, metadata, and more. The App Router consolidates this into app/layout.tsx.

Here's a typical Pages Router setup:

tsx
// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Provider } from 'react-redux';
import { store } from '@/lib/store';
import '@/styles/globals.css';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <Provider store={store}>
      <Component {...pageProps} />
    </Provider>
  );
}

Migrating to App Router, this becomes:

tsx
// app/layout.tsx
import type { Metadata } from 'next';
import { Provider } from 'react-redux';
import { store } from '@/lib/store';
import '@/styles/globals.css';

export const metadata: Metadata = {
  title: 'My App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="icon" href="/favicon.ico" />
      </head>
      <body>
        <Provider store={store}>
          {children}
        </Provider>
      </body>
    </html>
  );
}

A few key differences to watch. First, you're now writing JSX directly in the HTML structure rather than using the <Main /> placeholder. Second, metadata is now an exported object or function, not passed through props. Third, nested layouts are automatic—any layout.tsx in a subdirectory wraps its children without extra configuration.

I recommend creating your root layout early in the migration process, even if you're only migrating a handful of routes. This establishes the foundation and ensures all your migrated routes inherit the correct global setup from day one.

Replace Data Functions — getServerSideProps Becomes Async Server Components

This is where the philosophical shift from Pages Router to App Router becomes concrete. Pages Router fetches data in lifecycle functions like getServerSideProps, then passes it as props to a client component. App Router fetches data directly in Server Components, which are the default.

Let's compare a real example. Pages Router:

tsx
// pages/users/[id].tsx
import { GetServerSideProps } from 'next';

interface UserPageProps {
  user: {
    id: string;
    name: string;
    email: string;
  };
}

export default function UserPage({ user }: UserPageProps) {
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
  const { id } = context.params as { id: string };
  const res = await fetch(`https://api.example.com/users/${id}`);
  const user = await res.json();

  return {
    props: { user },
    revalidate: 60,
  };
};

App Router approach:

tsx
// app/users/[id]/page.tsx
interface UserPageProps {
  params: Promise<{ id: string }>;
}

export default async function UserPage({ params }: UserPageProps) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/users/${id}`, {
    next: { revalidate: 60 },
  });
  const user = await res.json();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Notice the component itself is now async. You fetch data directly in the component, no separate function. The revalidate option moves into the fetch options instead of the return object. This is simpler, more co-located, and reduces the number of abstractions you're managing.

For dynamic routes, App Router passes params as a Promise. This is intentional—it forces you to await params before using them, which aligns with async rendering. getStaticProps doesn't exist in App Router; use generateStaticParams() if you need static generation with dynamic segments.

The migration pattern here is straightforward: move your fetch logic from getServerSideProps into the component body, update the revalidate option syntax, and mark the component as async. If your fetch was conditional or had side effects, you may need to add a useEffect hook and mark the component with 'use client'—which brings us to the next section.

Watch Client Code — Hooks-Heavy Pages Need 'use client' Boundaries

By default, components in the App Router are Server Components. This is powerful for data fetching and security, but it means your interactive, stateful components need to opt into the client. You do this with the 'use client' directive at the top of the file.

Here's where I see most teams stumble during migration. They move a Pages Router component to App Router, forget the 'use client' directive, and get cryptic errors about hooks not being available in server components.

A typical scenario: you have a settings page with forms and local state:

tsx
// app/dashboard/settings/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';  // Note: 'next/navigation' not 'next/router'

export default function SettingsPage() {
  const [name, setName] = useState('');
  const [saved, setSaved] = useState(false);
  const router = useRouter();

  const handleSave = async () => {
    await fetch('/api/settings', {
      method: 'POST',
      body: JSON.stringify({ name }),
    });
    setSaved(true);
    setTimeout(() => router.refresh(), 1000);
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSave(); }}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Your name"
      />
      <button type="submit">Save</button>
      {saved && <p>Saved successfully!</p>}
    </form>
  );
}

A few things to note here. The 'use client' directive comes first. You're importing useRouter from 'next/navigation', not 'next/router'—this is one of the most common migration mistakes. The hook behavior is identical, but the import path changed.

The strategy I recommend: keep your Server Components lean. Use them for data fetching and layout. Push interactive logic to the leaves of your component tree and wrap only those child components with 'use client'. This keeps your bundle smaller and preserves the benefits of server-side rendering for most of your page.

tsx
// app/dashboard/settings/page.tsx
// This is a Server Component
import SettingsForm from './settings-form';

export default async function SettingsPage() {
  const settings = await fetch('https://api.example.com/settings');
  const data = await settings.json();

  return (
    <div>
      <h1>Settings</h1>
      <SettingsForm initialData={data} />
    </div>
  );
}

// app/dashboard/settings/settings-form.tsx
// This is a Client Component
'use client';

import { useState } from 'react';

export default function SettingsForm({ initialData }: any) {
  const [name, setName] = useState(initialData.name);
  // ... form logic
}

This pattern maximizes server-side rendering while keeping interactivity where it's needed.

Common Mistakes

  • Forgetting 'use client' on interactive pages. If you use hooks, event handlers, or state, you need 'use client' at the component level. The error messages from Next.js are usually clear, but they're also easy to miss during a busy migration.
  • Importing useRouter from the wrong module. App Router uses 'next/navigation'; Pages Router uses 'next/router'. A find-and-replace is your friend here, but double-check the imports.
  • Leaving conflicting routes in both directories. If you migrate a route to App Router, delete or rename the Pages Router version. Conflicts can cause subtle bugs where sometimes the old route loads and sometimes the new one does.
  • Not updating middleware patterns. Middleware in Pages Router (via _middleware.ts) is now handled with middleware.ts at the root. The API has changed significantly, so plan time for this if your application uses middleware heavily.
  • Assuming API route migrations are straightforward. Pages Router API routes export handler functions; App Router uses route handlers with named exports for HTTP methods. The logic is usually the same, but the wrapper is different.

Key Takeaways

  • Run both routers in parallel. The App Router takes precedence, so migrate routes one at a time without fear of breaking production.
  • Start with your root layout early. Migrate _app.tsx and _document.tsx into app/layout.tsx to establish the foundation for all migrated routes.
  • Embrace async components for data fetching. Move getServerSideProps logic into the component body and lean on the async component pattern.
  • Use 'use client' strategically. Mark only the components that need interactivity as client components, keeping the bulk of your application on the server.
  • Change your import paths. useRouter comes from 'next/navigation' in App Router, not 'next/router'.
  • Plan your migration in phases. Start with API routes and public pages, then move to higher-risk routes as your confidence grows.

This migration path works because it's incremental and safe. You're not betting the entire application on a cutover. You're learning the App Router patterns on low-risk routes, building confidence, and then scaling up. After working through dozens of migrations across different team sizes and codebases, I can tell you this approach consistently delivers faster than trying to migrate everything at once.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles