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.
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:
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
:
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.
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:
- We moved all files from the temporary
/app/new
directory to/app
. - We renamed
/pages
to/legacy-pages
to resolve the conflicting routes. - 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.
Our customers also noted the improvements:
Summary
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.