Migrating to Next.js App Router with zero downtime

Can you really adopt Next.js App Router incrementally? At WorkOS, we learned that you can’t really migrate a complex app page by page without a hit to the UX. Instead, we worked out a migration guide that allowed us to test our entire app with App Router while still serving the Pages Router to users—before making the final switch.

We use Next.js for all of our frontend apps at WorkOS. Recently, there has been a lot of discussion in the community about all of the new technologies that the Next.js App Router offers, such as nested layouts and React Server Components.

We were eager to try the App Router features to improve how our apps load, how we fetch data, and how we compose our interfaces. After a couple of prototypes, the UX and DX improvements were clear. But how do you make fundamental changes in a complex app that multiple teams contribute to daily? We started planning a migration strategy to make that possible.

This blog post dives into what we discovered when upgrading the WorkOS Dashboard, which is our main frontend app, to Next.js 14 and its App Router.

App Router challenges

In theory, Next.js is designed to support incremental migration to the App Router. That is, you can use both /app and /pages directory in the same app and gradually migrate page by page at your own pace.

However, there is a catch: navigating between pages on different routers is like navigating between two unrelated apps. For example, if you create a new page in the /app directory, but your current /pages directory app is a single page application (SPA) that displays a loading state while it’s bootstrapping, you’ll see that loading state any time you navigate between the routers.

Example of how the loading state is displayed when navigating between directories

This seemed like a big user experience setback considering that one of the primary goals behind the migration was to improve our loading states. Did this mean we’d have to migrate to /app router in a single big push?

Migration strategy

Migrating to the /app router in one big push seemed feasible, but it risked disrupting ongoing product work. Usually, at any time, there are at least a few pending dashboard PRs, making it impractical to maintain progress during a complete file structure rewrite. This would lead to constant merge conflicts and risky rebases. Stalling all product work until the migration is complete was also not a viable option.

Instead, we worked out a way to keep the /pages directory around as long as needed, while simultaneously working on the App Router version of the dashboard.

Step 1: Upgrade Next.js

First, we had to upgrade from Next.js 12 to 14, maintaining the /pages directory as is and only addressing the related breaking changes. This was as simple as going through the official migration guides for Next.js 13 and Next.js 14.

Step 2: Migrate routing hooks

Before App Router can be used, you need to replace your useRouter hook with the equivalent hooks provided by next/navigation.

Similar to the previous step, we just followed the official guide on Migrating Routing Hooks. This involved some legwork, but it allowed our code to function with both routers. While we remained in the /pages directory, we were gradually moving closer to compatibility with the new router.

Fortunately, we had not used either getStaticProps or getServerSideProps in our app, so there was no need to make our pages compatible with the App Router.

Step 3:  Create a temporary /app/new directory

Up next, we wanted to test the App Router in parallel to the existing /pages directory. Naturally, you can’t define the same route both in /app and in /pages.

Instead, we started re-creating our routing under the /app/new folder. This allowed us to access a URL structure similar to what the /pages directory provided.

Step 4: Import existing code from /pages

Each file in the /pages directory exports a regular React component. This meant that we could import those in the app router and reuse them:

import Page from 'src/pages/about';

export default AboutPage = () => {
  return <Page />

At this point, we still had the /pages code running normally. The rest of the engineering team was still shipping new work in the dashboard codebase, but now we could access any page under the /new/* slug to test it in the App Router.

There were just a few missing pieces to address before we could test our app in the App Router end-to-end:

  • All links in the App Router were still pointing into the /pages version of the app
  • All logic related to the current page pathname wouldn’t work correctly under the /new/*  slugs

Step 5: Use a query param to access the App Router

Finally, to test the dashboard in the App Router end-to-end, we had to make sure that the navigation stayed within the App Router, and that the page pathname logic worked correctly in either router.

To solve this, we added the following rule in next.config.js :

async rewrites() {
  return [
    // Serve `/new/path` if `?new=true` is present
      source: '/:path*',
      has: [{
        type: 'query',
        key: 'new',
        value: 'true'
      destination: '/new/:path*'


Now, when a URL contained a ?new=true query param, Next.js would serve the corresponding page located under the /new/* slug. You could access the App Router version of the dashboard with that ?new=true query param and the pathname logic designed for the original /pages directory would still work correctly.

Lastly, we added a temporary template file that hijacked all the links in the App Router and appended the ?new=true query param. Once you were inside the app router, you could navigate to other pages without escaping into the /pages directory code.

'use client';

import { useRouter } from 'next/navigation';
import * as React from 'react';

 * This template is temporary so we can override Next.js links to include `?new=true` query param
export default function Template({ children }: React.PropsWithChildren) {
  const router = useRouter();

  // Hacky solution taken from this issue:
  React.useEffect(() => {
    window.push = (pathname: string) => {
      const url = new URL(pathname, window.location.origin);
      url.searchParams.set('new', 'true');

    function isAnchorOfCurrentUrl(currentUrl: string, newUrl: string) {
      const currentUrlObj = new URL(currentUrl);
      const newUrlObj = new URL(newUrl);
      // Compare hostname, pathname, and search parameters
      if (
        currentUrlObj.hostname === newUrlObj.hostname &&
        currentUrlObj.pathname === newUrlObj.pathname && ===
      ) {
        // Check if the new URL is just an anchor of the current URL page
        const currentHash = currentUrlObj.hash;
        const newHash = newUrlObj.hash;
        return (
          currentHash !== newHash &&
          currentUrlObj.href.replace(currentHash, '') ===
            newUrlObj.href.replace(newHash, '')

      return false;

    function findClosestAnchor(
      element: HTMLElement | null,
    ): HTMLAnchorElement | null {
      while (element && element.tagName.toLowerCase() !== 'a') {
        element = element.parentElement;

      return element as HTMLAnchorElement;

    function handleClick(event: MouseEvent) {
      try {
        const target = as HTMLElement;
        const anchor = findClosestAnchor(target);
        if (anchor) {
          const currentUrl = window.location.href;
          const newUrl = anchor.href;
          const isAnchor = isAnchorOfCurrentUrl(currentUrl, newUrl);
          const isDownloadLink = !== '';

          const isPageLeaving = !(
            newUrl === currentUrl ||
            isAnchor ||

          if (isPageLeaving) {
            // Cancel the route change

            const urlWithNew = new URL(newUrl);
            urlWithNew.searchParams.set('new', 'true');

      } catch (err) {

    // Add the global click event listener
    document.addEventListener('click', handleClick, true);

    // Clean up the global click event listener when the component is unmounted
    return () => {
      document.removeEventListener('click', handleClick, true);
  }, [router]);

  return <>{children}</>;


Step 6: Remove the /pages directory

After a couple of more rounds of bug fixes, we were confident in making the switch to the App Router:

  1. We moved all files from the temporary /app/new directory to /app.
  2. We renamed /pages to /legacy-pages to resolve the conflicting routes.
  3. We removed that useEffect and the rewrite that were needed for testing the App Router.

Using the App Router: an RSC + React Query hybrid

At this point, we were free to implement the features that the App Router had to offer.

In the dashboard, the data is fetched on the client using React Query and GraphQL. This generally works well and changing to go all-in on React Server Components would be a much larger refactor. However, to make the dashboard feel better right away, we did employ RSC in a few load-bearing code paths.

Mainly, we migrated the following pieces of the app to render on the server:

  • Authentication and all the data related to the current user and their team
  • Feature flags related to the current user
  • Dashboard homepage
  • Key queries that are used often. These are hydrated with React Query on the client.

The difference was dramatic. It was refreshing to see the new experience of logging into the dashboard: with no loading state or layout shift in-between.

Before and after the page loading improvements that were enabled by the App Router

Our customers also noted the improvements:


We’ve covered what the migration strategy might look like without disrupting ongoing work, and touched on the benefits that React Server Components can bring when applied in the right places. Thanks for following along, and we hope that you enjoyed this guide.

In this article

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.