Featured resource
pluralsight tech forecast
2025 Tech Forecast

Which technologies will dominate in 2025? And what skills do you need to keep up?

Check it out
Hamburger Icon
  • Labs icon Lab
  • Core Tech
Labs

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.

Labs

Path Info

Level
Clock icon Beginner
Duration
Clock icon 45m
Published
Clock icon Nov 06, 2024

Contact sales

By filling out this form and clicking submit, you acknowledge our privacy policy.

Table of Contents

  1. 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 at app/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 and pages 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

    Use the triple-dot menu on a file or folder in the File Editor UI to create new files or folders

    There are two ways to create new files and folders in the lab environment:

    1. Using the File Tree interface. The "..." (vertical) menu button on a folder will let you create new files and folders.
    2. Using the Terminal tab with the mkdir -p <directory> and touch <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?
    
  2. 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-most layout.js module, at app/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 the children 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?
  3. 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 a app/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 includes useState, useCallback, and useMemo.

    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 a components/ 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.

  4. 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 new next/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 the params or query object. However, in app router this has been simplified by passing any dynamic route paramters as a params 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 the useParams hook from next/navigation.

    Something subtle is happening here you should be aware of.

    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 current pathname for the route. This has been replaced by the usePathname 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.
  5. 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 a page 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 a layout.js component and uses the native React children 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 layout app/layout.js thanks to the children React prop.

    Similarly, page.js components become the children of the layout.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 dedicated layout.js file and change page 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 with html and body tags
    • app/nested/layout.js -- The nested route layout which adds an <h1> title element
    • app/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.

  6. Challenge

    Migrating next/head Metadata

    In the pages router, adding metadata to the HTML <head> of the document was done using the next/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 and generateMetadata.

    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 a page.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 be async). The return value is the same metadata object but this will allow you to access route parameters passed in as a params 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.
  7. 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 and getStaticProps 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 run npm run build and npm start to test it.

    This behavior is mostly consistent with the getStaticProps function in pages router, except getStaticProps 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 native fetch method options which can be adjusted to the desired behavior:

    • cache: 'no-store' will match the behavior of getServerSideProps and never cache the data so it is always rendered fresh
    • cache: 'force-cache will match the behavior of getStaticProps and is the default in Next.js 14 Unlike the migrated getStaticProps 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 the fetch runs on every request.

    Migrating request access logic

    In getServerSideProps, the incoming Request 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 the next/headers module and import the cookies or headers 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) and getAll(). 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 from next/navigation which you've already seen in the Breadcrumb component. There is also the useSearchParams hook for accessing querystring values. Route params are still passed to the server component, just as they were to getServerSideProps.

    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 the revalidate 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 the revalidate option for ISR but they are separate features.

    The getStaticPaths function is replaced with a generateStaticParams function and the fallback parameter is replaced with an exported const dynamicParams:

    // 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/1

    You 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.

  8. 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 route params (if the route segment is dynamic)
    • the functions are not passed a Response but instead must return a Response 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 the NextResponse 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.

  9. 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 the Response object and thrown Error, 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 support next 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 a fallback 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 no Response object available to error.js components.

    Similar to nested layouts, a page will use the nearest error.js file if a client error occurs. If an error occurs, the Error object is forwarded as the error 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 an error.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 or res to customize props, that needs to be removed or rewritten.
    • The err object itself only contains a message and digest property in production for client-side security purposes.
    • A reset callback is passed to the component that allows users to retry re-rendering the ErrorBoundary children.

    You will need to read more about error handling in the app router to migrate any complex code you used to have.

  10. 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.

  11. 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.

  12. 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 from next/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 the next.revalidate fetch option
    • getStaticPaths is replaced with generateStaticParams
    • 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.

Kamran Ayub is a technologist specializing in full-stack web solutions. He's a huge fan of open source and of sharing what he knows.

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.