Next.js App Router 遷移の仕組みと実装
Next.jsのv13.4が発表され、App RouterがStableになりました。App Routerは発表以来着実に実装が進んでおり、最近もServer ActionやParallel Routesなどの新機能が次々と発表されています。
当然ながらこれらの話題はフレームワーク利用者目線の話題が多いのですが、本稿はApp Routerがどう実装されているのか、筆者の興味のままに遷移処理周りを中心に調査したまとめ記事になります。知っておくと役に立つ点もあるかと思うので、参考になれば幸いです。
Next.jsの遷移とprefetch挙動
Next.jsの遷移を理解するには、まずprefetch挙動について知る必要があります。今回は調査用のデモとして、pagesとappそれぞれで同じようなページをいくつか用意しました。

pagesで実装したページ

appで実装したページ
これらで従来のpagesとappの挙動を比較していきたいと思います。
pagesの挙動
pagesの場合、静的ファイルは積極的にprefetchされます。

SSGは当然ながら静的ファイルを作成するので、prefetchの対象となります。具体的にはJSファイルや画像、getStaticPropsなどの実行結果をシリアライズしたJSONなどです。
一方でSSRページの場合、getServerSidePropsの結果だけはリンクが実際に押下された時に取得されます。具体的にはhttp://localhost:3000/_next/data/Czj63Y47_EGJEXlNGwfI6/pages/example_dynamic.jsonのようなURLでfetchしてJSONを取得します。そのため、押下直後のリクエスト発火からレスポンスを受け取るまでの間は、直前の画面が表示されることになります。
以下で言うと、1~4まではずっと直前の画面が表示されていることになります。
- あらかじめJSファイルなどはprefetchされる
- SSRページに遷移するリンク押下
-
getServerSidePropsの結果をfetch開始 - ↑のレスポンスをJSONで受け取る
- ページ遷移
appの挙動
一方でApp RouterにはgetServerSidePropsはなく、Server Componentsのレンダリング時にデータ取得が行われます。
export default async function Products() {
const res = await fetch("https://dummyjson.com/products");
const data = await res.json();
return (
...
);
}
App Routerはこのような、従来のgetServerSidePropsでやっていたようなAPIへのfetch処理を含むページなどの場合も積極的にprefetchします。

App Routerによるprefetch時のURLはページとまったく同じhttp://localhost:3000/app/example_dynamicという形ですが、RSC: 1というhttpヘッダーを含み、このヘッダーがあるときは文字通りReact Server Components(略してRSC)としてレスポンスされます。ブラウザのURLアドレスバーで上記URLを入力すると、当然ブラウザのデフォルトヘッダーにRSC: 1はついていないのでhtmlとしてレスポンスされます。
このように、App Routerは積極的にprefetchを行うことで、従来のpagesとは違い直前の画面が表示されて待たされると言うことがほぼなく、即座に遷移が発生するような体験がデフォルトになります。もちろん、prefetchはOFFにすることが可能で、OFFの際にはpages同様fetch完了までは直前の画面が表示されますが、デフォルト挙動が変更されたのは大きな方針変更と言えるかと思います。
App Routerのprefetchはさらに細く仕様が存在しますが、これらは後述します。
pagesとappの境界を越える際の挙動
pagesとappは共存可能です。仕様も実装も大きく異なるRouterを持ったこれらのページを、どうやってNext.jsは共存させているのでしょう?
これを実現するのは単純な仕組みで、pagesとappの境界を越える時にはSPA遷移ではなくMPA(=Multi Page Application)遷移、つまりJS制御による擬似遷移ではなくブラウザのデフォルト挙動の遷移を行うことで実現しています。これによりそれぞれのRouterが、互いの影響を考慮する必要がほとんどなくなります。
pagesからappへの段階的リリースを伴うマイグレーションを検討している方は、移行段階においてSPA遷移が一部失われることを認識しておくと良いかと思います。
App Routerは遷移体験の改善を目論む
積極的なprefetchによって実現されるのは高いパフォーマンスです。具体的には新たにCore Web Vitalsに追加されたINP(Interaction To Next Paint)が改善されたSPA遷移を提供することを目指していると考えられます。前述の通り、従来のpagesでは画面押下直後は画面が応答していないように見えてしまうことから、INP的に優れた体験とは言えませんでした。App Routerでは積極的なprefetchによって、この問題を解決しようとしていると考えられます。
一方でこの積極的なprefetchは、BFFの裏側にあるAPIサーバーやデータベース負荷を高めてしまうことにつながるであろうことは明白です。個人的見解を含みますが、App Routerはこの負荷的な懸念を、Cache戦略を持ってカバーしているというスタンスなのではないかと思います。fetch単位・page単位のrevalidateを使いこなすことで、ユーザーにとってのパフォーマンスだけでなく負荷軽減も見込めます。
App Routerの遷移実装
App Routerの遷移やprefetchの仕様について見てきましたが、ここからはこれらの実装を追ってみたいと思います。
App Routerの状態管理
App Routerは内部的に状態管理をuseReducerを使って作成した、Reduxもどきで管理しています。ここであえて「Reduxもどき」と表現したのは、Redux Devtoolsと連携しているためです。Redux Devtoolsを入れていると、App Routerの状態管理がRedux Devtoolsによって可視化されます。
ただし、PromiseやReactElementをStateに含んでいるため、reducerを介さず非同期に更新されたり見づらかったりするので、Redux DevtoolsでApp RouterのStateを見るときは参考程度にすることをお勧めします。
prefetchアクション
next/linkから提供されるLinkコンポーネントはvisible、hover、touchStartイベント時にrouter.prefetchを呼び出します。
router.prefetchは外部URLやBot判定をしたのち、prefetchアクションをdispatchします。
prefetchアクションのreducerでは、fetchを発行しますが意図的にawaitしておらず、StateにそのままPromiseを保持します。具体的には、StateのprefetchCache: Map<string, PrefetchCacheEntry>でurlとCacheを管理しており、このPrefetchCacheEntry内のdataがReturnType<typeof fetchServerResponse> | nullとなっています。
実際にStateにPromiseを詰めているのは以下です。
遷移時はこのStateに保持したPromiseが解決済みなら値を取り出すような形で実装されています。
React Flight
prefetchアクションによって作成されるこのPromiseの実態は、ReturnType<typeof fetchServerResponse>の通りサーバーへのfetchであり、前述の通りページURLにRSC: 1などいくつかのヘッダーを含めたGETリクエストです。このServer ComponentsのGETリクエストのレスポンスボディは、FlightやReact Flightと呼ばれる独自のデータフォーマットで表現されます。
1:HL["/_next/static/css/5cc6c563bf8ab1da.css",{"as":"style"}]
0:[[["",{"children":["app",{"children":["example_static",{"children":["__PAGE__",{}]}]}]},"$undefined","$undefined",true],"$L2",[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/5cc6c563bf8ab1da.css","precedence":"next.js"}]],["$L3",null]]]]
4:I{"id":"7846","chunks":["272:static/chunks/webpack-6365542cc30a6aab.js","769:static/chunks/8e422d1d-436056157c89b00f.js","365:static/chunks/365-6e63437f7129d097.js"],"name":"","async":false}
5:I{"id":"6650","chunks":["272:static/chunks/webpack-6365542cc30a6aab.js","769:static/chunks/8e422d1d-436056157c89b00f.js","365:static/chunks/365-6e63437f7129d097.js"],"name":"","async":false}
6:I{"id":"7371","chunks":["371:static/chunks/371-975f2f5092fc69c3.js","980:static/chunks/app/app/example_dynamic/page-2bccbb99522030af.js"],"name":"","async":false}
2:[["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","div",null,{"className":"flex min-h-screen flex-col items-center justify-between p-24","children":["$","div",null,{"className":"w-full max-w-5xl","children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","app","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","app","children","example_static","children"],"error":"$undefined","errorStyles":"$undefined","loading":"$undefined","loadingStyles":"$undefined","hasLoading":false,"template":["$","$L5",null,{}],"templateStyles":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined","asNotFound":false,"childProp":{"current":[["$","main",null,{"children":[["$","h1",null,{"className":"mb-4 text-3xl font-extrabold text-gray-900 dark:text-white md:text-5xl lg:text-6xl","children":[[["$","span",null,{"className":"text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400","children":"`app`"}],"Â "],"example_static"]}],["$","p",null,{"className":"text-lg font-normal text-gray-500 lg:text-xl dark:text-gray-400","children":"This is an example page."}],["$","div",null,{"className":"mt-10","children":[["$","h2",null,{"className":"mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-4xl lg:text-4xl","children":"Links"}],["$","ul",null,{"className":"list-decimal pl-5","children":[["$","li",null,{"children":["$","$L6",null,{"href":"/app","className":"underline","children":"/app"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/app/example_static","className":"underline","children":"/app/example_static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/app/example_dynamic","className":"underline","children":"/app/example_dynamic"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages","className":"underline","children":"/pages"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages/example_static","className":"underline","children":"/pages/example_static"}]}],["$","li",null,{"children":["$","$L6",null,{"href":"/pages/example_dynamic","className":"underline","children":"/pages/example_dynamic"}]}]]}]]}]]}],null],"segment":"__PAGE__"},"styles":[]}],"segment":"example_static"},"styles":[]}],"segment":"app"},"styles":[]}]}]}]}]}],null]
3:[[["$","meta",null,{"charSet":"utf-8"}],["$","title",null,{"children":"Create Next App"}],["$","meta",null,{"name":"description","content":"Generated by create next app"}],null,null,null,null,null,null,null,null,["$","meta",null,{"name":"viewport","content":"width=device-width, initial-scale=1"}],null,null,null,null,null,null,null,null,null,null,[]],[null,null,null,null],null,null,[null,null,null,null,null],null,null,null,null,[null,[["$","link",null,{"rel":"icon","href":"/favicon.ico","type":"image/x-icon","sizes":"any"}]],[],null]]
FlightはStreamingを意識した仕様のため、1行ずつ読むようなフォーマットになっています。先頭部分のみ省けば、JSON配列っぽく読めると思います。なんとなくコンポーネント情報やprops、childrenの情報などが見て取れます。おそらく$はコンポーネントを指しているように思うのですが、正確な仕様は不明です。
ちなみにFlightの仕様とかを探してみたのですが、筆者には見つけることができませんでした。大抵はreact-server-dom-webpackによってFlightのレスポンスを実現しているようですが、ここやReactのRFCにもFlightの仕様書などはなさそうでした。
VercelにはReactコアチームのメンバーが多数いるので、この辺の仕様書はコアチーム内部に閉じてるのかもしれません。筆者が知らないだけで公開されてたらすいません、ご教示ください。
追記1: 以下に仕様がありました。koichikさんありがとうございます!
追記2: React Server ComponentsやFlightの仕組みについては以下の記事が詳しく解説してくれています。use clientディレクティブが登場するより前なので少し情報は古い部分もありますが、興味のある方はご一読ください。
navigageアクションと遷移判定
さて、Link押下時には、navigateアクションが発火します。navigateはいくつかのStateを更新して、遷移を指示するフラグや次のページのtreeを算出します。
具体的にはまず、prefetchした結果を元に遷移方法を決定します。prefetch結果がFlightでない場合、pages配下のページへの遷移と判定し、MPA遷移となります。他にも外部URLの場合やprefetch失敗時にMPA遷移となります。これはStateのpushRef.mpaNavigatioがtrueに変更され、Routerコンポーネント内で読み取られてlocation.assignが呼ばれることで実現しています。
app内での遷移の場合、Stateのtreeが更新され、InnerLayoutRouterに渡されます。
このInnerLayoutRouterではtreeに基づき順番にsubtreeが解決されていきます。
childNode.subTreeDataにはInnerLayoutRouterが含まれているので、一段深ぼったtreeを新たなProviderに渡すことで、再起的にInnerLayoutRouterを呼び出し、順々にLayoutを解決していきます。
Intercepting Routes
InnerLayoutRouterはLayout順に解決されていくわけですが、この際、Parallel RoutesやIntercepting Routesが解決された状態でtreeなどは更新されます。特にIntercepting Routesは、同じURLでも遷移元次第でUIが異なるので「どこから遷移しようとしているか」をどこかで判定しているわけです。
序盤で作成したデモにParallel RoutesとIntercepting Routesとを定義してみます。ここでは/app/feedから/app/postsへの遷移に対して、Interceptするようにしています。

これでprefetchの中身を見てみると、少々中身が異なることが確認できました。
0:[["children","app",["app",{"children":["posts",{"children":[["id","999","d"],{"children":["__PAGE__",{}]}]}]}],null,null]]
非intercept時のprefetch
0:[["children","app","children","feed","preview","(..)posts",["(..)posts",{"children":[["id","999","d"],{"children":["__PAGE__",{}]}]}],null,null]]
/app/feedからintercept時のprefetch
Intercepting Routesは内部的にはrewriteの一種として実装されています。prefetch時にヘッダーに付与されるNext-Urlが正規表現と一致するか検証するようなrewriteです。Next-Urlは遷移元のURLパターンが含まれるので、これにより特定のURLから特定のURLへの遷移をInterceptingしているというわけです。

next buildすると.next直下にroutes-manifest.jsonというJSONファイルで出力され、Intercepting Routesを含むrewriteの情報はこのJSONに記載されます。少々長いのでだいぶ省略しますが、中を見てみると以下のような記述が見つかります。
{
"version": 3,
...
"rewrites": {
"beforeFiles": [
{
"source": "/app/posts/:id",
"destination": "/app/feed/(..)posts/:id",
"has": [
{
"type": "header",
"key": "Next-Url",
"value": "\\/app\\/feed(?:\\/(.*))?[\\/#\\?]?"
}
],
"regex": "^/app/posts(?:/([^/]+?))(?:/)?$"
}
],
"afterFiles": [],
"fallback": []
}
}
next startで立ち上がるNext.jsのサーバーは、このroutes-manifest.jsonを元に幾つかのルーティングを決定しています。
今回筆者が調査したのはここまでです。Intercept周りまでなんとなく実装のイメージは掴めてきたので、満足しました。
感想
今回はNext.jsのApp Routerの遷移周りの実装を調査してみました。筆者はまだApp Routerをプロダクションで使ったことはなく、ちょっと試してみたりドキュメント読んだりしてたくらいだったので、仕組みをちゃんと追おうと思うと多くの学びや発見がありました。
特にServer Component周りについてはまだまだ理解が薄いことに気づきました。精進しようと思います。
あとStateに直接PromiseやReactElementを持っているのは結構驚きました。直接画面にprefetch結果が反映されるわけではないので、多くのユースケースとは異なるからこういうやり方なのかもしれません。
余談: ブラウザバック体験と履歴keyについて
あとたまたま目に入って気づいたのですが、pagesと変わらずApp Routerでもhistory.stateを管理しているので、開発者がhistory.stateを利用するとおそらく上書きされてしまいます。
つまり、開発者が自前で履歴を管理する手段はないわけです。
詳しくは以下の記事を参照いただきたいのですが、筆者はブラウザバック体験を損ねないため、履歴に紐づく状態管理が必要なケースは多いと考えています。
辛うじてpagesでは履歴のkeyがhistory.stateに含まれていましたが、今回はなさそうです。そもそも、pagesでもこれを参照してしまうのは内部実装に依存するので、本当は避けたいところです。結局Navigation API同様、Next.jsが履歴のkeyを提供するのが最もシンプルだと思って提案しているのですが、音沙汰ないのが現状です。
実はこれを直訴するためにVercel meetupにも参加して発表したりもしたのですが、変わらず。。。
明確に拒否されてるわけでもないですしブラウザバック体験はやはり重要な体験なはずなので、今後も迷惑にならない程度に、めげずに提案し続けてみようかと思います。
Discussion