Migrate from Nextjs Pages to App Router

July 10, 2024 · 8 min read

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.tsxinto a new file called/app/layout.tsx.Remove the
next/documentimport 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
/appthat 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.tsxfile.In the
page.tsxfile, 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, orgetStaticPathsfunctions 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(), anduseSearchParams()fromnext/navigationinstead ofuseRouter()fromnext/router.The new
useRouter()hook does not return thepathnameorqueryproperties. UseusePathname()anduseSearchParams()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.jsfile withapp/error.tsxto handle global errors.Create different
error.tsxfiles 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.

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.tsxLayout wrappers (header, footer, nav) move to
app/layout.tsxContext providers need a dedicated Client Component wrapper (because
app/layout.tsxis 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.
Update Next.js:
npm install next@latest react@latest react-dom@latest(App Router is stable from Next.js 13.4+)Create the
/appdirectory at your project rootMigrate
pages/_document.tsxtoapp/layout.tsx(root layout with<html>and<body>)Migrate
pages/_app.tsx: move global CSS imports toapp/layout.tsx, extract providers into a"use client"Providers componentMigrate each page:
pages/about.tsxbecomesapp/about/page.tsxReplace
getStaticPropswith async Server Components +fetch()Replace
getServerSidePropswith async Server Component +fetch(url, { cache: 'no-store' })Replace
getStaticPathswithgenerateStaticParams()Update router imports:
next/routerbecomesnext/navigation(androuter.querybecomesuseSearchParams())Update
<Link>tags: remove the child<a>element (no longer needed)Migrate
pages/api/toapp/api/[route]/route.tsRoute HandlersAdd
error.tsxandloading.tsxfiles per route segment where neededRun
next buildand resolve all TypeScript and build errorsRemove 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
windoworlocalStorage) running during server render. Move this code insideuseEffector usedynamic()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 intolayout.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

Stay up-to-date
Be updated with all news, products and tips we share!
On this page
- Upgrading your Next.js project
- Check your dependencies
- Create the `/app` directory
- Move files from Pages Folder to App Folder
- Migrate pages to the App Router
- Update data fetching
- Migrate routing hooks
- The Core Paradigm Shift: Server vs Client Components
- Migrating _app.tsx: The Trickiest File
- Migrating API Routes to Route Handlers
- Complete Migration Checklist
- Common Migration Errors and How to Fix Them
- Conclusion
- Bring Your Ideas to Life 🚀

