Next.js App RouterとPages Routerで同じルートを共存させる方法
はじめに
カウンターワークスの山田です。フロントエンド開発を担当しています。
Next.jsをPages RouterからApp Routerへ移行するとき、「同じURLパターンを両方のルーターで処理したい」という状況に直面することがあります。
例えば、新しいコンテンツはApp Routerで、レガシーコンテンツはPages Routerで処理しつつ、
URLは /posts/[post_id] のままにしたい、というケースです。
問題
https://example.com/posts/102 → App Router(新デザイン)
https://example.com/posts/90 → Pages Router(旧デザイン)
「片方のルーターで見つからなければ、もう片方にフォールバックしてくれるのでは?」と思うかもしれません。残念ながら、そうはなりません。
同じルートが両方に存在する場合、App Routerが常に優先されます。 ルーター間の自動フォールバックは存在しません。
my-app/
├── app/
│ └── posts/
│ └── [post_id]/
│ └── page.tsx # 常にこちらが優先
├── pages/
│ └── posts/
│ └── [post_id].tsx # 到達しない
解決策
fallback rewrites と dynamicParams = false を組み合わせることで解決できます。
Step 1: Pages Router側のルートをリネーム
まず、Pages Router側のルートをリネームして、直接の競合を避けます:
my-app/
├── app/
│ └── posts/
│ └── [post_id]/
│ └── page.tsx
├── pages/
│ └── posts-legacy/ # リネーム
│ └── [post_id].tsx
Step 2: fallback rewritesを設定
next.config.js でfallback rewritesを設定します。
fallback rewritesは、「どのルートにもマッチしなかった場合」にのみ発動する特殊なrewriteです。通常のrewriteは条件に合えば常に発動しますが、fallback rewritesは最後の砦として機能します。
// next.config.js
module.exports = {
async rewrites() {
return {
fallback: [
{
source: "/posts/:post_id",
destination: "/posts-legacy/:post_id",
},
],
};
},
};
この設定は「/posts/:post_id がアプリ内のどこにも存在しない場合、/posts-legacy/:post_id を代わりに表示する」という意味になります。
Step 3: dynamicParams = false を設定
App Routerに [post_id] という動的ルートがあると、デフォルトではすべての /posts/xxx にマッチしてしまいます。つまり、/posts/90をリクエストしても、App Routerが該当ルートを処理対象と判断し、fallback rewritesは発動しません。
これを防ぐために、App Router側で処理するIDを明示的に指定し、それ以外は「存在しない」扱いにする必要があります:
// app/posts/[post_id]/page.tsx
// App Routerで処理するpost IDを明示的に指定
export async function generateStaticParams() {
// 例: post 100~105のみApp Routerで処理
const newPostIds = [100, 101, 102, 103, 104, 105];
return newPostIds.map((id) => ({
post_id: id.toString(),
}));
}
// generateStaticParamsに含まれないIDは404扱いにする
// これによりfallback rewritesが発動する
export const dynamicParams = false;
dynamicParams = false を設定することで:
-
generateStaticParamsで返したID → App Routerで処理 - それ以外のID → 「存在しない」扱い → fallback rewriteが発動 → Pages Routerへ
実際のアプリケーションでは、このIDリストをAPIやデータベースから取得することが多いでしょう
export async function generateStaticParams() {
const response = await fetch('https://api.example.com/new-posts');
const posts = await response.json();
// ...
}
処理の流れ
リクエスト: /posts/102
↓
App Routerに存在する? → Yes(generateStaticParamsに含まれる)
↓
App Routerでレンダリング ✓
---
リクエスト: /posts/90
↓
App Routerに存在する? → No(dynamicParams = falseにより404扱い)
↓
Fallback rewrites発動 → /posts-legacy/90 に内部リライト
↓
Pages Routerでレンダリング ✓
(ブラウザのURLは /posts/90 のまま)
---
リクエスト: /posts/9999
↓
App Routerに存在する? → No
↓
Fallback rewrites発動 → /posts-legacy/9999
↓
Pages Routerにも存在しない → 404ページ表示
別解: Proxyを使う方法
dynamicParams を使いたくない場合は、Proxyで制御することもできます。
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const LAST_LEGACY_POST_ID = 107;
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const match = pathname.match(/^\/posts\/(\d+)$/);
if (match) {
const postId = parseInt(match[1] ?? "0", 10);
if (postId <= LAST_LEGACY_POST_ID) {
return NextResponse.rewrite(
new URL(`/posts-legacy/${postId}`, request.url)
);
}
}
return NextResponse.next();
}
export const config = {
matcher: '/posts/:path*',
};
このProxy(proxy.ts)はすべてのリクエストで実行されるため、毎回fetchするとパフォーマンスに影響が出る恐れがあり、境界値(LAST_LEGACY_POST_ID)をハードコードする必要がありますが dynamicParams を使わずに済みます。
まとめ
Next.jsでApp RouterとPages Routerを同じURLパターンで共存させるには:
- Pages Router側のルートをリネームする
-
next.config.jsでfallback rewritesを設定する - App Router側で
dynamicParams = falseを設定する
特に3番目のステップを忘れると、動的ルートがすべてのリクエストをキャッチしてしまい、fallback rewritesが発動しません。
この方法を使えば、レガシーコンテンツを維持しながら、新しいコンテンツを段階的にApp Routerへ移行できます。
We are hiring!
カウンターワークスでは一緒に働く仲間を絶賛募集中です。
今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!
参考
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion