- Lab
- Core Tech

Guided: Migrating to App Router in Next.js 14
This interactive lab guides you through the process of migrating from the pages router to the app router in Next.js 14. You will incrementally adapt a simple application, encountering and resolving expected errors along the way. Each section focuses on migrating specific APIs, such as router hooks and data-fetching methods, to demonstrate the differences between the two routers. Along the way you’ll learn about React Server Components (RSC), router hooks, special files, and metadata APIs. By the end of the lab, you’ll have a clear understanding of the app router’s new structure and how to apply it in your projects.

Path Info
Table of Contents
-
Challenge
Preparing for the Migration
In this lab, you will migrate a simple demo Next.js app that uses the Pages router to App router step-by-step. You'll see how to migrate code that uses different APIs related to SSR, routing, and metadata, and learn the differences between the two routers.
info> Stuck on a task? Check out the
solutions
folder in the project directory for full solution files and reference code for each step. Files begin with a number corresponding with the task order in the step, followed by a directory path structure using~
. For example,step-2/01-app~layout.js
corresponds to Step 2, Task 1 for the module located atapp/layout.js
.What to expect
You should already be familiar with Next.js 14 or below when using the Pages router. You do not have to have any experience with React Server Components or App router -- that's what you'll learn about in this lab!
The lab will walk through an incremental migration, teaching you a repeatable process you can follow for your own apps and what to consider along the way.
info> This lab uses JavaScript but Next.js supports TypeScript as well. The same migration steps apply for whichever language you are using. ## Two routers, one app Migrating to app router does not have to be a single massive effort. The
app
andpages
router directories can co-exist in Next.js and this will be key for performing an incremental migration during the lab.Managing files and folders
There are two ways to create new files and folders in the lab environment:
- Using the File Tree interface. The "..." (vertical) menu button on a folder will let you create new files and folders.
- Using the Terminal tab with the
mkdir -p <directory>
andtouch <filename>
commands. Use these commands if you feel more comfortable at the command-line.
For example, a typical migration in the lab involves creating a folder, file, and deleting, which can be done in the Terminal like this:
# Create a directory structure mkdir -p app/slots/[id] # Create an empty file touch app/slots/[id]/page.js # Remove a folder rm -rf pages/slots # Remove a file rm pages/index.js # Verify a file or folder is deleted (list format) ls -l pages ``` info> **Tip:** If you notice the _File Editor_ UI is not reflecting a new file or folder, you can expand/collapse the folder twice to refresh it. For deleted files and folders, it may not update until you refresh the window. You can use the `ls` command to list the contents of a folder to verify files/folders are deleted on the underlying lab file system. Now that the app router directory is created, you can begin the migration. Where would you start?
-
Challenge
Migrating _app and _document to a Root Layout
The best place to start your migration is with the
_app.js
and_document.js
pages files.In the app router, these files are combined into a root layout.
What is a root layout?
In the pages router, the
_app
and_document
files allowed you to create a global shared layout. You could create per-page layouts but it required additional configuration.In the app router, layouts can be specified with a
layout.js
module at any route segment and they will be nested within each other. The root layout is simply the top-mostlayout.js
module, atapp/layout.js
.Plain HTML over custom components
In pages router, you needed to use custom Next.js components from
next/document
to build a document wrapper:Before: Pages Router
import { Html, Head, Main, NextScript } from "next/document"; export default function Document() { return ( <Html lang="en"> <Head /> <body> <Main /> <NextScript /> </body> </Html> ) }
In app router, you no longer need to use any Next.js components from
next/document
-- app router layouts work with plain HTML JSX elements and manages metadata and scripts for you automatically.Pages as children slots
In addition, rather than rendering pages using the
<Main />
component, layouts rely on a simpler "slot" rendering pattern where Next.js passes page components as thechildren
prop.After: App Router
export default function RootLayout({ children }) { return ( <html lang="en"> <body> {children} </body> </html> ) } ``` After the `_document` module is migrated, you can then migrate the contents of the `_app` module. In the pages router, the `_app` module was used for global imports and configuration. All of this can now be moved to the root layout, which is similarly used globally for all app router pages. ## Migrating Global Styles You will need to migrate any global imports (like styles) from `_app` to the new root layout. In addition, the demo app uses TailwindCSS which watches specific folders in the workspace. You will need to update the configuration to support watching files in the `app` router directory. If you view the demo app at {{localhost:3000}}, the homepage still renders the same as it did before. Next.js understands the `pages/index.js` route still exists and the app router layout isn't used. Why don't you get this party started by migrating that initial index route over to app router?
-
Challenge
Migrating Pages
From route files to route folders
In the pages router, each route is a file but in the app router, every route is a folder and uses a special file convention that corresponds to different routing elements. You already saw the
layout.js
convention to create a Layout, now you will create a Page.Page components
A
page.js
module is the convention the app router uses to render pages. A "page" is just a React component that renders some JSX:export default function HomePage() { return ( <div> {/* rest of the page */} </div> ) }
It's very similar to the pages router but there are some key differences as you'll discover shortly.
Migrating the homepage
To migrate the
pages/index.js
route, you will move the code into aapp/page.js
file. Now try refreshing {{localhost:3000}}. It should look the same, but behind the scenes you are now using the app router for the homepage.info> See a 404 page? You may need to stop and restart the dev server for it to pick up migrated routes.
This is a simple migration because the homepage is static and has no data fetching or interactivity. If you have static pages in your site like this, migrate them first.
Migrating an interactive page
Take a look at the {{localhost:3000}}/client route. The page has a simple interactive counter that increments when the button is pressed. Try visiting {{localhost:3000}}/client and you should see an error like this:
You're importing a component that needs useState. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default.
Server vs. client components
In the pages router, page components are rendered on the client and pre-rendered on the server. This means they can contain hooks but
useEffect
hooks (or other stateful hooks) are not run on the server. These are called "client components."In the app router, this distinction is made even more strict. All pages in the app router are rendered on the server by default. They are called "server components."
This was done to make it more explicit where the boundary is between the server and the client. This also allows Next.js to make many optimizations to the initial page load with server components to ship less JS and HTML.
However, it does require rethinking the way your component tree is designed.
React Server Components (RSC)
React Server Components have two primary differences from client components:
- They may not contain any stateful hooks (like
useEffect
,useState
,useCallback
). - They may be async functions
React Server Components Support Stateless Hooks
It's important to distinguish between stateless hooks vs. stateful hooks. React Server Components do support stateless hooks but any hooks that contain effects are not supported. Stateful hooks are essentially any hooks that use
useEffect
which includesuseState
,useCallback
, anduseMemo
.In contrast, stateless hooks do not rely on any effects. This is why you can still use some libraries and their hooks within React Server Components if they are compatible.
Creating client components
To add interactivity to a page in the app router, you need to extract interactive code into client components with the
"use client";
directive at the top of the module."use client"; import { useState } from "react"; export default function Counter() { const [counter, setCounter] = useState(0); /* rest of code */ }
Client components can be stored alongside the
page.js
server component in the same directory, or you can put them anywhere in your application source directory, like acomponents/
folder.info> In the pages router, you could not keep other components next to route files since every file is considered a route. In the app router, that's no longer the case, and you can co-locate components according to where they are used. The {{localhost:3000}}/client route should now be working again.
A repeatable migration process
If you want to ensure all new app router pages act the same as the pages router, you can follow the process laid out in the previous task.
Migrating old pages into client components makes sure all the previous functionality in the pages router works the same in the app router without having to remove or refactor hooks.
This will form the basis of your migration to app router and you will repeat it throughout the lab. Now it's time to see what additional steps are required to migrate various pages APIs to app router.
- They may not contain any stateful hooks (like
-
Challenge
Migrating Router Hooks
If you have any components that use the Next.js routing hooks from
next/router
, these will need to be migrated to the newnext/navigation
APIs.If you view the {{localhost:3000}}/slots/1 page, you should see a page that displays the current pathname and dynamic ID route parameter value.
The code reads the dynamic route parameter,
[id]
and prints it to the page with the current URL path.Migrating dynamic route segments
In the pages router, dynamic route segments were denoted by the bracket syntax in the filename like
pages/slots/[id].js
.In the app router, the same convention is used except the route parameter becomes the folder name, like
app/slots/[id]/page.js
Since app router pages are server components and they can't use stateful hooks, the usage of routing hooks requires that code be migrated into client components first. If you open {{localhost:3000}}/slots/1, you will see an error due to the deprecated
useRouter
hook:Error: NextRouter was not mounted.
The hook relies on
next/router
which is only supported for the pages router. The functionality this provided has been simplified with the app router but it does require changes to your code.Reading dynamic route parameters
The old
useRouter
provided route parameters with theparams
orquery
object. However, in app router this has been simplified by passing any dynamic route paramters as aparams
prop to the page component.export default function Page({ params }) { const { id } = params; /* rest of code */ }
This means you no longer need a hook to read parameters from the route and can take advantage of React Server Components. You would then pass the parameter down to any child components.
What if you need to read params in a client component?
Using the
params
prop in the server page component is optimal since it doesn't require any hooks. If you need to dynamically render code based on the route parameters in a client component, you can use theuseParams
hook fromnext/navigation
.Mixing server and client components
You are effectively passing data from the server down to a client component. Essentially, you are mixing server and client components seamlessly together.
info> A server component can render a client component, but not the other way around. Plus, the props you pass to a client component from a server component must be serializable, which means you cannot pass callbacks for example.
There's one more change needed to finish migrating the page.
Reading pathname
The
useRouter
hook returned the currentpathname
for the route. This has been replaced by theusePathname
hook.import { usePathname } from "next/navigation"; export default function Breadcrumb() { const pathname = usePathname(); /* rest of code */ } ``` At this point, you should now be able to view the migrated page at {{localhost:3000}}/slots/1 and you should be able to see the ID update if you click the various links. ## Hard vs. soft Navigations When you load the `/slots/1` route initially, it is rendered on the server. However, when you click the links on the page, the app router is performing the navigation on the client. That initial server load is sometimes called a "hard navigation" and the client-side navigation is called a "soft navigation." This works the same way in the app router as it did in the pages router with the `next/link` component. ## Simplifying server and client Components The page has been successfully migrated but you should be getting the sense that the current code is not optimal. After all, the client `Page` component just exists from before but all it does is pass the `id` prop to the `Breadcrumb` component. The other JSX within the `Page` is static, it's only the `Breadcrumb` that requires hooks. You can safely refactor and simplify the `client.js` module now to separate the server component from the client component. The result is now what you would want in a final migration to app router, where the server and client aspects have been separated. ## Lift server code up and push client code down In other words, when migrating to app router it's best to **lift server code to the top of the route** and **push client code down** into components. This allows you to render as much as you can on the server and maintain clearer separation for client components.
-
Challenge
Migrating Nested Layouts
In the pages router, nested layouts were created using the static
Page.getLayout
API (and needed changes to your_app.js
code).export default function Page() { return (<div>Nested</div>); } Page.getLayout = (page) => ( <div className="wrapper"> {page} </div> );
Nested layouts have been simplified in the app router and use the
layout.js
file convention you've already seen previously.Nesting with React children
The
getLayout
function used apage
parameter for the page component. Nesting layouts works works the same way in the app router except instead of a method, the code is moved to alayout.js
component and uses the native Reactchildren
prop.export default function WrapperLayout({ children }) { return ( <div className="wrapper"> {children} </div> ); }
So, a nested layout
app/nested/layout.js
becomes the child layout of the root layoutapp/layout.js
thanks to thechildren
React prop.Similarly,
page.js
components become thechildren
of thelayout.js
they are next to (if defined).This creates a very natural way to nest layouts that is obvious based on the file system.
Migrating getLayout API
Nested layouts require changes to your code, but the migration is simple: move the code in
getLayout
to a dedicatedlayout.js
file and changepage
parameter to{ children }
. When you visit {{localhost:3000}}/nested, the component tree is rendered like this:app/layout.js
-- The root layout wrapping the app withhtml
andbody
tagsapp/nested/layout.js
-- The nested route layout which adds an<h1>
title elementapp/nested/page.js
-- The page component for the nested route which has the<h2>
subtitle element
Using the
layout.js
module file convention, Next.js will automatically nest layouts appropriately.Similar to app router pages, app router layouts are server components by default.
-
Challenge
Migrating next/head Metadata
In the pages router, adding metadata to the HTML
<head>
of the document was done using thenext/head
component:Before: Pages router metadata
import Head from "next/head"; export default function Page() { return ( <div> <Head> <title>Page with Metadata</title> <meta property="og:title" content="Page with Metadata" /> </Head> </div> ); }
This has been replaced by two metadata APIs:
metadata
andgenerateMetadata
.Migrating static metadata
If your metadata doesn't need to be dynamic based on route parameters or external data, you can export a plain
metadata
object from apage.js
module containing any metadata fields you want to set for the page.After: App router static metadata
export const metadata = { title: "Page with Metadata", } export default function Page() { return ( /* JSX */ ); } When you visit the {{localhost:3000}}/metadata in the **Web Browser** tab, you may not see the page title in the lab environment. Click the arrow icon to open the URL in a new tab to see it in the native browser window, which should display the "Page with Metadata" title. info> Refer to the Next.js docs for the [full metadata schema](https://nextjs.org/docs/14/app/api-reference/functions/generate-metadata#metadata-fields). ## Migrating dynamic metadata If your metadata needs to read a route parameter or fetch data from an API, the `metadata` object won't work. In the pages router, since the `Head` component is "just" React, you could use expressions to substitute dynamic data: **Before: Pages router dynamic metadata** ```jsx import Head from "next/head"; /* assumes blogPost is provided by server props */ export default function Page({ blogPost }) { return ( <div> <Head> <title>{blogPost.title}</title> </Head> </div> ); }
With the app router, for dynamic metadata you can export a
generateMetadata
function (which may also beasync
). The return value is the same metadata object but this will allow you to access route parameters passed in as aparams
prop similar to page components.After: App router dynamic metadata
export async function generateMetadata({ params }) { const { id } = params; const blogPost = await getBlogPost(id); return { title: blogPost.title } } ``` When you open the {{localhost:3000}}/metadata/1 route, you should now see a dynamic page title. Try changing the ID to 2 or 3 for example. When you migrated the dynamic metadata page to app router, did you notice you also switched it from being a client component to a server component? The old code relied on the `useRouter` hook but as you learned previously, you no longer need a hook to access route parameters -- they are passed to page and layout components as well as the `generateMetadata` function. As you are performing your migration, remember that if you can remove stateful hooks from your pages (or push them further down), they'll be able to take advantage of being React server components.
-
Challenge
Migrating Data Fetching Methods
The pages router offered several server-side rendering APIs (aka SSR) for data fetching as exported functions from a route module:
getServerSideProps
getStaticProps
getStaticPaths
With the app router, both
getServerSideProps
andgetStaticProps
are replaced by React Server Components (RSC).Async server components
React server components can be
async
which means you can fetch data within the component. As a shorthand, these are called async server components:export default async function Page() { /* fetch blog posts on the server */ const blogPosts = await getBlogPostsAsync(); /* render list of blog posts on the server */ return ( <div> {blogPosts.map(post => (<BlogPost {...post} />))} </div> ) }
info> Good to know: The primary difference between a non-async RSC and async RSC is that async components cannot use hooks at all (whether stateless or stateful).
Migrating getStaticProps
In the pages router,
getStaticProps
runs once on build and then is never run again.In Next.js 14, this is also how all React Server Components behave. In other words, data fetching is cached by default.
The migration is simple: remove the
getStaticProps
function and move all the logic/data handling to the RSC directly. Open {{localhost:3000}}/data/static-props and try refreshing the page a few times.You should notice two things:
- The Fetch Time stays the same on every request.
- The Render Time changes every request.
Fetch Time is the timestamp retrieved from the remote server -- it's not changing because by default, Next.js caches data fetching once until it is invalidated manually.
Render Time is the timestamp of when the page is rendered on the server. In development, it changes on every request but in production mode, it would build once and remain static every time you request the page.
info> Since the lab environment does not have network access,
next build
will not work since the remote API is unavailable so you cannot preview the lab demo in production mode. Instead, you can download the lab files locally and runnpm run build
andnpm start
to test it.This behavior is mostly consistent with the
getStaticProps
function in pages router, exceptgetStaticProps
runs for every request in development so Fetch Time would change. In app router, data caching is now separate from rendering.As you just saw, there is no additional code needed to migrate
getStaticProps
-- this is already the default behavior for React Server Components.Migrating getServerSideProps
In the pages router,
getServerSideProps
runs for every request on the server so the page reflects the most recent data.Since this method is removed in React Server Components, and it only renders the data once on build by default, what if you want to ensure Fetch Time is always updated and rendered on every request?
Next.js 14 added a
cache
policy property to the nativefetch
method options which can be adjusted to the desired behavior:cache: 'no-store'
will match the behavior ofgetServerSideProps
and never cache the data so it is always rendered freshcache: 'force-cache
will match the behavior ofgetStaticProps
and is the default in Next.js 14 Unlike the migratedgetStaticProps
page, when you visit {{localhost:3000}}/data/server-side-props in the Web Browser tab, you will see both timestamps change on every request.
When you pass the
no-store
cache policy, Next.js will ensure thefetch
runs on every request.Migrating request access logic
In
getServerSideProps
, the incomingRequest
object was passed to the function so you could access headers, cookies, or even the URL:export function getServerSideProps({ req }) { const userAgent = req.headers['user-agent']; const cookieNames = Object.keys(req.cookies ?? {}); /* logic */ }
In the app router, server components cannot access the incoming
Request
. Instead, you will need to rewrite your code to use thenext/headers
module and import thecookies
orheaders
request helpers:import { cookies, headers } from "next/headers"; export default function Page() { const userAgent = headers().get("user-agent"); const cookieNames = cookies().getAll().map((cookie) => cookie.name); /* rest of component */ }
info> The API is different for the cookies and headers functions. This requires rewriting your request logic to use the new methods like
get(key)
andgetAll()
. Try refreshing the {{localhost:3000}}/data/request route again. It should display the same data as before.info> If you were accessing the request URL, you will need to rewrite your code to use a client component and the
usePathname
hook fromnext/navigation
which you've already seen in theBreadcrumb
component. There is also theuseSearchParams
hook for accessing querystring values. Routeparams
are still passed to the server component, just as they were togetServerSideProps
.Migrating ISR
By default,
getStaticProps
will cache a page once during the build phase. If you want to have Next.js automatically rebuild the page statically, you could do so by specifying therevalidate
option:export function getStaticProps() { return { revalidate: 10 }; }
This is called Incremental Static Regeneration (ISR). Setting
revalidate
to 10 would have Next.js invalidate the static page after 10 seconds, so a subsequent request would trigger a rebuild.info> The page is regenerated on the next request, not in the background.
In the app router, revalidation is set by passing the
next.revalidate
fetch option:fetch(url, { next: { revalidate: 10 } })
The behavior ends up being the same but now revalidation is directly tied to data fetching and individual requests, not the entire page. This allows for better optimizations like Streaming, Suspense, and more. Try visiting {{localhost:3000}}/data/isr to see the result.
Like before with
/data/static-props
, the Fetch Time remains static initially. However, if you wait for 10 seconds or longer, than refresh the page, the timestamps both update as a new fetch is triggered.Migrating getStaticPaths
For dynamic route segments, the pages router also supported the
getStaticPaths
function to generate individual static pages:export function getStaticPaths() { const ids = [1, 2, 3, 4, 5]; const paths = ids.map((id) => ({ params: { id: id.toString() } })); return { paths, fallback: "blocking" }; }
In this example, during the build phase, Next.js would generate 5 static pages like:
- /posts/1
- /posts/2
- and so on
info>
getStaticPaths
is often used alongside therevalidate
option for ISR but they are separate features.The
getStaticPaths
function is replaced with agenerateStaticParams
function and thefallback
parameter is replaced with an exported constdynamicParams
:// default, equivalent to fallback: 'blocking' export const dynamicParams = true; export function generateStaticParams() { const ids = [1, 2, 3, 4, 5]; const paths = ids.map((id) => ({ id: id.toString() })); return paths; }
The return value of
generateStaticParams
is simplified to just returning an array of parameter objects that correspond to the dynamic route segment parameter (e.g./posts/[id]
). Try visiting {{localhost:3000}}/data/isr/1You should see the ID value printed along with the timestamps. Similar to before, after 10 seconds, refreshing the page will rebuild and fetch a new time. Try visiting the routes for the other IDs as well, which should act the same way.
Understanding cache behavior
When migrating to app router, it's important to understand the way Next.js handles data fetching and caching because you may need to adjust the way your data fetching works in your own code and for downstream libraries.
info> For a deep dive into Next.js data fetching, check out the "Learning Rendering and Data Fetching" module in the Next.js Foundations course.
-
Challenge
Migrating API Routes
To migrate the data fetching methods previously, the server was invoking a
fetch
against the running Next.js server API route.Try visiting {{localhost:3000}}/api/timestamp for example.
This returns the current UTC timestamp of the request in JSON:
{ "datetime": "<UTC timestamp>" }
Introducing Route Handlers
In the pages router, API routes exported a default
handler
function that accepted request and response parameters:export default function handler(req, res) { res.status(200).json({ greeting: 'Hello' }); }
Since the Response object was passed to the handler, you could use it to set the status and content. You did not need to return anything from the handler, which acted more like native Node.js middleware APIs.
In the app router, instead of calling them API routes, these are now called Route Handlers.
The end result is essentially the same but there are some key differences in the API:
- you need to export individual functions, not a
default
- the functions must be named after the supported HTTP method (like
GET
,POST
, and so on) - the functions are only passed the incoming
Request
and the routeparams
(if the route segment is dynamic) - the functions are not passed a
Response
but instead must return aResponse
object
They look similar but different to API routes:
import { NextResponse } from 'next/server'; // example: /api/hello export function GET(req) { return NextResponse.json({ greeting: 'Hello' }); } // example: /api/posts/[id] export function POST(req, { params }) { return NextResponse.json({ id: params.id }); }
Route handlers are specified using the
route.js
module:// In Pages router: pages/api/hello.js // In App router: app/api/hello/route.js
This means the app router supports having pages, layouts, and route handlers in the same directory. Furthermore, if you were fetching remote data in your API route in order to use it in a component can now simply be moved into the React server page component. Now try visiting the {{localhost:3000}}/api/timestamp URL again.
It still displays the same output and behaves the same way.
API routes require refactoring
The demo is simple and only deals with a GET request, but if your original API routes had conditional logic based on the HTTP method, you will need to refactor them to separate exported functions. Additionally, rather than using the
Response
object passed into the handler, you can use theNextResponse
object to create a response.Testing the migration
Since pages API routes and app router handlers can co-exist, your migration strategy depends on how complex your API is and your existing test strategy.
For example, you may decide to create a new version of the API (like
/api/v2/*
) so you can write integration tests that compare the results of both endpoints and ensure the output is identical.On the other hand, if you already have integration or end-to-end tests that test the API routes, you can safely refactor API routes and run your existing tests to detect any regressions.
- you need to export individual functions, not a
-
Challenge
Migrating Special Files
You are almost at the end of the migration but you may also have special files that differ in how they are handled in the app router.
Migrating custom 404 pages
In the pages router, you could define a
404.js
page that customizes 404 responses.export default function NotFound() { return ( <h1> 404: Page Not Found </h1> ); }
In the app router, the only change needed is to rename the file to
not-found.js
. Try visiting a non-existent URL in the app like {{localhost:3000}}/unknown.A custom 404 page with red text should be displayed instead of the built-in Next.js 404 page.
info> Good to know: App router supports nested
not-found.js
files in any route segment. The root file is used as a catch-all for any unknown route but you can also redirect manually to the nearest Not Found page.Migrating custom error pages
In the pages router, you could define an
_error.js
file that would customize error handling globally for the application on both the client and the server:export default function Error({ error }) { return ( <h1>An error occurred</h1> <h2>{error.message}</h2> ); } Error.getInitialProps = ({ res, err }) => { return { error: err }; };
The
_error.js
file allowed you to specify initial props that could be derived from theResponse
object and thrownError
, which meant it could be used for both server and client-side error handling.info> You cannot preview the
pages/_error.js
file as it only runs in production and the lab environment does not supportnext build
.First start by migrating the static
/error
page since that triggers the custom error. If you visit {{localhost:3000}}/throw-error, the default Next.js error page with the stack trace is displayed.The pages router supported customizing a single global error page. In the app router, every route segment can customize error fallback UI to be shown instead of the default error UI or development stack trace.
Introducing Error Boundaries
In the app router, the
_error.js
file has been replaced by customizable Error Boundaries.A React
ErrorBoundary
is a special component that uses afallback
UI when any child within it encounters an exception:<ErrorBoundary fallback={<Error />}> <ComponentThatThrows /> </ErrorBoundary>
Instead of defining a single global error handler for client and server errors, you can define an
error.js
file at any route segment to customize the error boundary fallback UI. As a result, there is noResponse
object available toerror.js
components.Similar to nested layouts, a page will use the nearest
error.js
file if a client error occurs. If an error occurs, theError
object is forwarded as theerror
prop to the component:"use client"; export default function Error({ error }) { return ( <div> <h1>An error occurred</h1> <h2>{error.message}</h2> </div> ); }
info> Good to know:
error.js
modules must be client components. Refresh or open the {{localhost:3000}}/throw-error page again and you should now see a custom message with red text instead of the default stack trace.Customizing errors requires a rewrite
As you can tell,
error.js
files use an entirely different architecture than_error.js
.The
_error.js
file cannot be easily refactored into anerror.js
file unless it is very simple like this demo app.error.js
files are React client components- If you were previously relying on
getInitialProps
orres
to customize props, that needs to be removed or rewritten. - The
err
object itself only contains amessage
anddigest
property in production for client-side security purposes. - A
reset
callback is passed to the component that allows users to retry re-rendering theErrorBoundary
children.
You will need to read more about error handling in the app router to migrate any complex code you used to have.
-
Challenge
Migrating Global Context Providers
The "Copyright" bar at the bottom of the page is affixed in place with a component that relies on a global React context.
Global context providers in _app.js
In the pages router, you specified global Context providers in
_app
, like this:import { CopyrightContextProvider, Copyright } from "@/contexts/copyright"; import "@/styles/globals.css"; export default function App({ Component, pageProps }) { return ( <CopyrightContextProvider> <Component {...pageProps} /> <Copyright /> </CopyrightContextProvider> ); }
Global context providers like these can be moved to the Root Layout and can wrap the
children
slot.With one caveat...
React context is not used on the server
Since React context is not used on the server, in the app router global context providers must be marked as client components with the
"use client";
directive.This means they cannot directly be added to the
app/layout.js
file (which is a server component), they need to be separated out into a client component module and imported. Now when you refresh any migrated page, the copyright bar should show up.info> You do not need to migrate the
getLayout
logic in_app.js
because this is replaced with nested layouts that you already migrated!Context provider considerations
It may be tedious to update all context provider modules to be client components, or in many cases you may not have control over third-party providers.
One approach is to define a single
Providers
client component module that provides all your global context providers:"use client"; import { CopyrightContextProvider } from '@/contexts/copyright'; import { SomeOtherProvider } from 'third-party-library'; export default function Providers({ children }) { return ( <CopyrightContextProvider> <SomeOtherProvider> {children} </SomeOtherProvider> </CopyrightContextProvider> ) }
Your root layout can then wrap
children
with the<Providers>
component.This will allow you to migrate global providers piecemeal to app router to maintain compatibility.
info> The
"use client";
directive is safe to use within the pages router. -
Challenge
Finalizing the Migration
It's time to celebrate because you've successfully migrated away from the pages router and are fully running on the app router!
Cleaning up the pages directory
The remaining files in the
pages
directory should be:_app.js
_document.js
404.js
_error.js
These files only apply to the pages router and do not affect the app router, which is why they are safe to remain until you finish your migration effort.
Now that you're done with the migration, you can safely delete the
pages
directory. The pages router is now fully removed from {{localhost:3000}}.info> Seeing an
Internal Server Error
? You may need to restart the dev server since page compilation data may be cached from the pages directory.Depending on your migration strategy, you may decide to keep API routes around longer than pages. You can still safely delete the special files once no more pages remain as they don't apply to API routes.
-
Challenge
Recap
Migrating to app router in many ways is simple but there are plenty of "gotchas" to be aware of that may not make it easy.
To recap the major differences in app router compared to pages router you just experienced:
_app
and_document
are replaced with a Root Layout- Nested layouts do not need any extra logic
- All app router pages are React Server Components by default which run on the server
- Server Components cannot use stateful hooks but can be marked
async
- Async Server Components cannot use any hooks
- Components that use stateful hooks must be marked as Client Components with the
"use client";
directive - Server Components can seamlessly render and pass data to Client Components (as long as the props are serializable)
next/router
is replaced with new APIs fromnext/navigation
- Data fetching methods like
getStaticProps
,getServerSideProps
are replaced with React Server Components - Data fetching is cached by default in Next.js 14 and you can adjust it using the
cache
fetch option - ISR revalidation is moved down to the
fetch
level with thenext.revalidate
fetch option getStaticPaths
is replaced withgenerateStaticParams
- Custom 404 pages remain essentially the same
- Global error handling is replaced with customizable error boundary fallback components
- Global context providers must be client components
Additional resources
For a deep dive into Next.js, watch the Next.js Foundations course.
For official documentation on migrating to app router, you can refer to the Next.js App Router Migration guide. However, you should be pleased to know this lab had you handle some cases that weren't covered in the docs.
What's a lab?
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Provided environment for hands-on practice
We will provide the credentials and environment necessary for you to practice right within your browser.
Guided walkthrough
Follow along with the author’s guided walkthrough and build something new in your provided environment!
Did you know?
On average, you retain 75% more of your learning if you get time for practice.