Serve the same route from Next.js App Router and Pages Router
Recently, I launched the new issue template for this newsletter, which runs on the Next.js App Router. Instead of migrating the old template running on the Pages Router, I decided to leave all the old issues there and publish new issues using the new template on the App Router. Migrating wasn't really an option because the data retrieval method for the old template is fundamentally different from what I had in mind for the new one.
I will go into more detail about how I built the new website in a future post, but for now, I want to focus on a specific trick I used.
My goal was to keep both the old and new issues running on the same route: /issues/[issue_id]. When you request an old issue, it should render the page defined in the Pages Router. When you request a newer issue, it should use the path in the App Router.
You might think you can implement this behavior just by adding the same path in both routers, assuming that if an issue ID doesn't exist in one router, it will fallback to the other before throwing a 404 error. For example, calling issue 1 using the path /issues/1 would first check if it exists in the App Router (it doesn't), then see if it exists in the Pages Router (it does), and finally render it.
However, the default behavior in Next.js is that when there are two equal routes in the App and Pages routers, the one in the App Router takes precedence. So, if you want to conditionally use the App or Pages router for the exact same path, you need to find a different approach.
my-app/
|-- app/
| |-- layout.tsx
| |-- issues/
| |-- [issue_id]/
| |-- page.tsx # <- this route will take precendence
|-- pages/
| |-- issues/
| |-- [issue_id].tsx
|-- next.config.mjs
|-- package.json
Solution 1
Since there is no way to have the exact same route active in both routers simultaneously, I had to rename one of them. I decided to change the one in the Pages Router from issues/[issue_id] to issues-page/[issue_id].
Inside the proxy.ts file (formerly known as middleware.ts), I check the issue_id in the route. Depending on a hardcoded issue ID, the middleware either lets the request through to the App Router or catches it and redirects it to the Pages Router route issues-page/. The twist here is that we use the NextResponse.rewrite() method to change how the path looks in the browser. This ensures the rendered page always appears to be on the clean /issues/[issue_id] route.
// proxy.ts
const LAST_PAGES_ROUTER_ISSUE_ID = 107;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const match = pathname.match(/^\/issues\/(\d+)$/);
if (match) {
const issueId = parseInt(match[1] ?? "0", 10);
if (issueId <= LAST_PAGES_ROUTER_ISSUE_ID) {
return NextResponse.rewrite(
new URL(`/issues-page/${issueId}`, request.url)
);
}
}
return NextResponse.next();
}
Although this works, I wasn't happy with the solution due to the hardcoded LAST_PAGES_ROUTER_ISSUE_ID and the overall implementation style. Luckily, I found a much better way.
Solution 2
I knew my instinct to use rewrites was correct, so I looked up the Next.js docs related to rewrite configuration in next.config.mjs and found exactly what I was looking for. There are rewrite options for many use cases, but I was specifically interested in the fallback rewrite.
You can use this to check if a route exists in your Next.js app and, if not, fallback to a different route. In the docs example, they show how to fallback to an external URL if the path doesn't exist in your app. This is a great way to incrementally migrate to Next.js, and for me, it is the perfect way to bridge the gap between the Pages Router and the App Router.
// next.config.mjs
module.exports = {
async rewrites() {
return {
fallback: [
{
source: "/issues/:issue_id",
destination: `/issues-page/:issue_id`,
},
],
};
},
};
Basically, here is what this config does: it checks if the issue exists inside the App Router first. If not, it checks if it exists inside the Pages Router (via the renamed route). If it finds it there, it renders it under the original source path. If it's not in either, it shows a 404 page.
I love this solution. Objectively, it is much more elegant than the first attempt.
Thanks for reading! Hope this was helpful to you ❤️