Migrate from Nextjs Pages to App Router

Lokman Musliu Founder and CEO of Lucky Media
Lokman Musliu

July 10, 2024 · 8 min read

Nextjs logo fast development

Upgrading your Next.js project

Let’s migrate from Next.js’s traditional Pages Router to the new App Router. This migration is designed to bring your application into a new age of routing efficiency and flexibility. The App Router improves the developer experience by offering better file system-based routing capabilities and the new React Server Components among other things.

Check your dependencies

It’s important to ensure that your package.json file is updated. Check all dependencies to confirm they are compatible with the new App Router, and upgrade them if necessary. This preliminary step will help avoid potential compatibility issues during the migration process.

You can quickly check with npm by running:

npm outdated

Create the `/app` directory

The first step is to create a new /app directory at the root of your Next.js project. This is where you will place all the files and components for the App Router.

Move files from Pages Folder to App Folder

The /pages/_document.tsx file is used to customize the HTML document that is rendered on the server. In the App Router, this functionality is handled by the /app/layout.tsx file.

  • Copy the contents of /pages/_document.tsx into a new file called /app/layout.tsx.

  • Remove the next/document import and replace the <Html>, <Head>, and <Main /> components with their HTML equivalents (<html>, <head>, and {children}).

  • Remove the <NextScript /> component.

Migrate pages to the App Router

For each page in your /pages directory, you will need to create a corresponding folder structure in the /app directory.

  • Create a folder structure in /app that matches the URL path of your page. For example, if you have a page at /pages/about.tsx, you would create an /app/about/page.tsx file.

  • In the page.tsx file, copy the contents of your original page component.

  • If your page component uses any client-side functionality (e.g., hooks, browser APIs), you will need to wrap it with the 'use client' directive at the top of the file.

Update data fetching

In the App Router, the traditional Next.js data fetching methods (getStaticProps, getServerSideProps, getStaticPaths) are no longer used. Instead, you can directly fetch data within your page components.

  • Remove any getStaticProps, getServerSideProps, or getStaticPaths functions from your page components.

  • Fetch data directly within your page components using standard JavaScript/TypeScript asynchronous functions.

Example 1: Fetching data

Before (Pages Router):

// /pages/about.tsx
import { GetStaticProps } from 'next';

export const getStaticProps: GetStaticProps = async () => {
  const data = await fetchSomeData();
  return {
    props: { data },
  };
};

const AboutPage = ({ data }: { data: any }) => {
  return (
    <div>
      <h1>About Page</h1>
      <p>{data.message}</p>
    </div>
  );
};

export default AboutPage;

After (App Router):

import { fetchSomeData } from '@/lib/data';

const AboutPage = async () => {
  const data = await fetchSomeData();

  return (
    <div>
      <h1>About Page</h1>
      <p>{data.message}</p>
    </div>
  );
};

export default AboutPage;

Migrate routing hooks

The App Router introduces new routing hooks that replace the ones used in the Pages Router:

  • Use useRouter(), usePathname(), and useSearchParams() from next/navigation instead of useRouter() from next/router.

  • The new useRouter() hook does not return the pathname or query properties. Use usePathname() and useSearchParams() instead.

Example 2: Migrating routing hooks

Before (Pages Router)

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

const UserPage = () => {
  const { query } = useRouter();
  const userId = query.id as string;

  return (
    <div>
      <h1>User Page</h1>
      <p>User ID: {userId}</p>
    </div>
  );
};

export default UserPage;

After (App Router):

// /app/users/[id]/page.tsx
'use client';

import { usePathname, useSearchParams } from 'next/navigation';

const UserPage = () => {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const userId = searchParams.get('id');

  return (
    <div>
      <h1>User Page</h1>
      <p>User ID: {userId}</p>
    </div>
  );
}

export default UserPage;

Update error handling

The App Router has a different approach to error handling compared to the Pages Router:

  • Replace the pages/_error.js file with app/error.tsx to handle global errors.

  • Create different error.tsx files within your page folders to handle specific route-level errors.

Example 3: Migrating error handling

Before (Pages Router)

// /pages/_error.js
import { NextPageContext } from 'next';

const ErrorPage = ({ statusCode }: { statusCode: number }) => {
  return (
    <div>
      <h1>Error {statusCode}</h1>
      <p>An error occurred on the server</p>
    </div>
  );
};

ErrorPage.getInitialProps = ({ res, err }: NextPageContext) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404;
  return { statusCode };
};

export default ErrorPage;

After (App Router):

// /app/error.tsx 
// Error components must be Client Components!
'use client';

interface ErrorPageProps {
  error: Error & { digest?: string }
  reset: () => void;
}

const ErrorPage = ({ error, reset }: ErrorPageProps) => {
  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
      <button onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }>
		Try again
	  </button>
    </div>
  );
};

export default ErrorPage;

Migrate other features

Depending on your application, you may need to migrate other features, such as API routes, middleware, and custom document/app components. Refer to the Next.js documentation for guidance on how to migrate these features to the App Router.

Test and verify

After completing the migration, test your application to ensure all functionality works as expected. Pay close attention to any differences in behavior between the Pages Router and the App Router.

Remember, the App Router and Pages Router can coexist in the same Next.js application, allowing for an incremental migration approach. This can be helpful if you need to retain certain legacy features that are not yet supported in the App Router.

Next.js 15 - release date

The Core Paradigm Shift: Server vs Client Components

This is the most important concept to understand before you migrate a single file. In the App Router, every component in your /app directory is a Server Component by default. Server Components render on the server and ship zero JavaScript to the browser. This is fundamentally different from the Pages Router, where all components ran on the client.

You opt into client-side rendering by adding "use client" at the top of a file. Any component that imports from a "use client" file also becomes a Client Component.

// Server Component (default) - no directive needed
// Can be async, fetches data on the server, ships no JS
const PostList = async () => {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};

export default PostList;
// Client Component - must opt in
'use client';

import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
};

export default Counter;

Use "use client" when your component needs:

  • React hooks (useState, useEffect, useRef, custom hooks, etc.)

  • Event handlers (onClick, onSubmit, etc.)

  • Browser APIs (window, localStorage, document)

  • Context providers (Redux, React Query, theme providers)

Key rule: Push "use client" as far down the component tree as possible. A Server Component can render a Client Component as a child, but a Client Component cannot import a Server Component.

Migrating _app.tsx: The Trickiest File

The pages/_app.tsx file is typically where global CSS is imported, providers are wrapped (Redux, React Query, theme), and layout components are rendered. The App Router splits this into different places:

  • Global CSS imports move to app/layout.tsx

  • Layout wrappers (header, footer, nav) move to app/layout.tsx

  • Context providers need a dedicated Client Component wrapper (because app/layout.tsx is a Server Component)

The provider pattern is what catches most developers off guard. Here’s the correct approach:

// app/providers.tsx  <-- NEW FILE (Client Component)
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider attribute="class">
        {children}
      </ThemeProvider>
    </QueryClientProvider>
  );
}
// app/layout.tsx  <-- Root layout (Server Component)
import './globals.css';
import { Providers } from './providers';

export const metadata = {
  title: 'My App',
  description: 'My app description',
};

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

Migrating API Routes to Route Handlers

In the Pages Router, API routes live in pages/api/ and export a single default function receiving req and res. In the App Router they become Route Handlers: files named route.ts that export named functions per HTTP method.

// BEFORE: pages/api/posts.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    res.status(200).json({ posts: [] });
  }
  if (req.method === 'POST') {
    const body = JSON.parse(req.body);
    // create post...
    res.status(201).json({ success: true });
  }
}
// AFTER: app/api/posts/route.ts
import { NextRequest } from 'next/server';

export async function GET() {
  return Response.json({ posts: [] });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  // create post...
  return Response.json({ success: true }, { status: 201 });
}

Dynamic API routes follow the same folder convention as pages. pages/api/posts/[id].ts becomes app/api/posts/[id]/route.ts. The id param is accessed from the second argument to the handler function:

// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  return Response.json({ id });
}

Complete Migration Checklist

Use this as your step-by-step guide. The Pages Router and App Router can coexist in the same project, so you can migrate incrementally — page by page — rather than all at once.

  1. Update Next.js: npm install next@latest react@latest react-dom@latest (App Router is stable from Next.js 13.4+)

  2. Create the /app directory at your project root

  3. Migrate pages/_document.tsx to app/layout.tsx (root layout with <html> and <body>)

  4. Migrate pages/_app.tsx: move global CSS imports to app/layout.tsx, extract providers into a "use client" Providers component

  5. Migrate each page: pages/about.tsx becomes app/about/page.tsx

  6. Replace getStaticProps with async Server Components + fetch()

  7. Replace getServerSideProps with async Server Component + fetch(url, { cache: 'no-store' })

  8. Replace getStaticPaths with generateStaticParams()

  9. Update router imports: next/router becomes next/navigation (and router.query becomes useSearchParams())

  10. Update <Link> tags: remove the child <a> element (no longer needed)

  11. Migrate pages/api/ to app/api/[route]/route.ts Route Handlers

  12. Add error.tsx and loading.tsx files per route segment where needed

  13. Run next build and resolve all TypeScript and build errors

  14. Remove the pages/ directory (except API routes still in progress)

Common Migration Errors and How to Fix Them

These are the errors you will hit most often during migration:

  • "useState can only be used in a Client Component" - You are using a hook in a Server Component. Add "use client" at the top of the file.

  • "Event handlers cannot be passed to Client Component props" - You are passing a function (onClick handler) from a Server Component to a child. Move the handler into the child component and mark it "use client".

  • Hydration mismatch errors - Usually caused by browser-only code (accessing window or localStorage) running during server render. Move this code inside useEffect or use dynamic() with { ssr: false }.

  • "async/await is not supported in Client Components" - Client Components cannot be async. Move data fetching to a parent Server Component and pass data down as props.

  • Context does not work / providers are not wrapping the tree - Your context provider is in a Server Component. Extract it to a separate "use client" Providers component and import it into layout.tsx.

  • Fetch requests not caching as expected (Next.js 15+) - In Next.js 15, fetch() is no longer cached by default. Add { next: { revalidate: 3600 } } to opt into caching, or use { cache: 'force-cache' } explicitly.

Conclusion

Migrating from Next.js’s Pages Router to the App Router is the most significant architectural shift in Next.js’s history. The Server Components model, the new file conventions, and Route Handlers require a mental model reset - but once it clicks, the result is a faster, leaner application with dramatically less client-side JavaScript. Migrate incrementally, tackle the _app.tsx provider pattern first, and use the checklist above to track your progress.


Bring Your Ideas to Life 🚀

If you need help with a React project let’s get in touch.

Lucky Media is proud to be recognized as a leading Next.js Development Agency

Technologies

Next.js
Lokman Musliu Founder and CEO of Lucky Media
Lokman Musliu

Founder and CEO of Lucky Media

Stay up-to-date

Be updated with all news, products and tips we share!

Let’s chat

We partner with a limited number of brands each quarter to ensure senior-level attention on every project.

lokman and arlind headshots
Teamwork

Related posts

January 1, 2024

Why do we use Next.js ?
Next.js