Next.jsとReact RouterのDockerイメージサイズとビルド時間を比較した
Next.jsとReact Routerを比較するときDockerイメージの大きさやビルド時間が判断材料として挙げられることがあります。ただ実際の運用ではそれだけでなくHTTPキャッシュをどこで担うかも構成に影響します。Dockerイメージがどこまで小さくできるのか、ビルドにどれくらい時間がかかるのか、アプリケーションの外側でHTTPレスポンスをキャッシュできるのかは分けて確認した方がよさそうです。どれもどちらのフレームワークを使うかだけで決まるものではないからです。
Dockerイメージについて言えばNext.jsではoutput: "standalone"を使うかどうかで本番用Dockerイメージに含まれるファイルが大きく変わります。ビルドについてはNext.jsが標準で統合しているwebpackとTurbopackのほかにNext.jsとは別のプロジェクトであるRspackをnext-rspackを介して利用することもできます。Rspackはwebpackとの互換性を重視しているため既存のwebpack向けローダーやプラグインを使っているアプリではTurbopackやVite/Rolldownへ置き換えるより変更を少なくできる可能性があります。キャッシュについてもApp Routerを使うからといって外側でキャッシュできなくなるわけではなく、通常のHTTPレスポンスとしてCDNやリバースプロキシに保存させる構成を取れます。
そこでNext.jsとReact Routerをできるだけ同じ条件で比較するための検証用リポジトリを作成しました。Dockerイメージサイズと本番ビルドにかかる時間を計測し、Next.js App RouterについてはVarnishを前段に置いて外部のHTTPキャッシュを利用した場合の挙動も確認しています。
比較条件
比較したのはNext.js 16.4.0と15.5.27、React Router 8.4.0です。React RouterはFramework Modeを主な比較対象としてData ModeとDeclarative Modeも参考として計測しました。Next.jsはApp RouterとPages Routerの両方を用意してwebpack、Turbopack、Rspackをそれぞれ使用しています。
アプリケーションには固定した内容を返すルートを20個とLaravelからデータを取得する動的なルートを30個用意しました。動的なルートはそれぞれ1から1,000までのIDを扱うためページ数としては30,000ページあります。各フレームワークではできるだけ同じ内容を返すようにしています。
Node.jsは24.21.0でDockerのベースイメージもすべてnode:24.21.0-alpineにそろえました。ビルド時間は依存パッケージのインストールを含めず本番用のビルドコマンドだけを計測しています。各構成は新しいコンテナから10回ずつビルドしてこの記事ではその中央値を使います。
Dockerイメージサイズにはローカルで展開した状態の大きさではなくレジストリへpushしたときの圧縮後の容量を使っています。Linux向けマニフェストが参照する設定情報とレイヤーの合計でdocker pullで転送される容量に相当します。今回使ったベースイメージだけで約59.2 MiBあります。
また今回の比較ではビルド時にHTMLを生成しないよう条件をそろえました。App Routerではdynamic = "force-dynamic"を指定してPages RouterではgetServerSidePropsを使っています。React Router Framework Modeもリクエストを受けたときにHTMLを生成します。
export const dynamic = "force-dynamic";静的レンダリングを有効にすると生成されたHTMLなどもビルド成果物に含まれるためフレームワークの違いと事前生成したファイルの量による違いを分けにくくなります。そのため今回はリクエスト時のレンダリングにそろえました。もちろん本番環境でも静的レンダリングを避けるべきという意味ではありません。事前に生成できるページであればリクエストごとにオリジンサーバーでHTMLを生成する必要がなく、外部のキャッシュを利用する場合でもキャッシュにないときの処理を減らせるためTTFBの面では有利です。今回のforce-dynamicは比較条件をそろえるための設定です。
Dockerイメージサイズ
Dockerイメージサイズの測定結果は次のようになりました。

React Router Framework ModeをVite/Rolldownでビルドした場合は66.6 MiBでした。Next.js 16ではApp Router + Turbopackが70.9 MiBでPages Router + Turbopackが71.0 MiBです。webpackとRspackでも73 MiB台でした。Next.js 15は75 MiB前後です。今回の条件ではNext.jsのDockerイメージがReact Router Framework Modeより何倍も大きくなるという結果にはなりませんでした。Next.js 16 + Turbopackとの差は4 MiB程度でApp RouterとPages Routerの違いもほとんどありません。React RouterのData ModeとDeclarative Modeはともに59.3 MiBでしたが、この2つはFramework Modeとは実行方法が異なるため主な比較対象にはしていません。
上の結果はいずれもNext.jsでoutput: "standalone"を有効にしたものです。standaloneを有効にするとOutput File Tracingによって実行時に必要と判断されたファイルだけが.next/standaloneへまとめられるため、本番用Dockerイメージには.next/standaloneと静的ファイルをコピーするだけで済みます。
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["node", "server.js"]standaloneを使わない構成では本番用のnode_modulesと.nextをコピーしてnext startで起動します。
COPY --from=production-deps /app/node_modules ./node_modules
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
CMD ["node_modules/.bin/next", "start"]今回の測定ではこの違いだけでDockerイメージサイズがほぼ2倍になりました。Next.js 16 Pages Router + Turbopackはstandaloneありで71.0 MiBでしたがstandaloneなしでは143.8 MiBになりました。webpackでも73.6 MiBから143.7 MiBへ増えています。Next.js 15 Pages Routerもstandaloneありでは75.3 MiBですがstandaloneなしでは146 MiB台でした。standaloneなしの構成では本番用のnode_modules全体をDockerイメージへ入れるためNext.js 16 App Routerでは約90 MiBのSWCバイナリなども含まれていました。
standaloneを使ったNext.jsとReact Router Framework Modeとの差は数MiBでしたがstandaloneを使わないNext.jsでは140 MiBを超えました。Next.jsのDockerイメージサイズをReact Routerと比較するときはoutput: "standalone"を使っているかどうかを無視できません。
standaloneほどではありませんが不要な依存パッケージを除外することでも差が出ました。sharpはoptionalDependenciesとしてインストールされますがnext/imageを使わないアプリでは必要ありません。今回のアプリでもnext/imageは使っていないためoutputFileTracingExcludesでsharpと@imgを除外した構成も計測しました。
import type { NextConfig } from "next";
const sharpExcludes = [
"node_modules/@img/**/*",
"node_modules/sharp/**/*",
];
const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingExcludes: {
"*": sharpExcludes,
"/*": sharpExcludes,
},
};
export default nextConfig;sharpを含む構成と比べると除外した構成ではDockerイメージが約8.4 MiB小さくなりました。next/imageを使わない場合はこの分を本番用Dockerイメージから外せます。
ビルド時間
本番ビルドを10回ずつ行った中央値は次のようになりました。

React Router Framework ModeをVite/Rolldownでビルドした場合は0.7秒でした。今回比較したサーバー側でレンダリングする構成の中では最も短い結果です。Next.js 16 App Routerはwebpackで13.2秒、Rspackで11.4秒、Turbopackで3.1秒でした。Pages Routerはwebpackが11.0秒、Rspackが10.0秒、Turbopackが2.7秒です。Rspackもwebpackより短くなりましたがTurbopackとの差はかなり残りました。
Next.js 15では結果が異なります。App Routerはwebpackが14.5秒、Rspackが12.5秒、Turbopackが17.1秒でした。Pages Routerはwebpackが12.2秒、Rspackが10.9秒、Turbopackが13.0秒です。Next.js 15ではRspackが3つの中で最も短くTurbopackはwebpackより長くなっています。
このように同じNext.jsでもバージョンとバンドラの組み合わせによってビルド時間は大きく変わります。特にNext.js 16ではApp Routerがwebpackの13.2秒からTurbopackの3.1秒へ、Pages Routerが11.0秒から2.7秒まで短くなりました。React RouterをVite/Rolldownでビルドした0.7秒という結果はNext.jsのどの構成と比べてもかなり短いものでしたが、Next.js 15 + webpackの14.5秒とReact Routerの0.7秒だけを比べてその差をそのままフレームワークの差と考えるのは難しそうです。
なお今回のアプリではwebpack向けの複雑なカスタマイズそのものは再現していません。そのためRspackを使った場合のビルド時間は比較できても実際のアプリで既存のローダーやプラグインをどこまで変更せずに利用できるかまでは検証していません。
App Routerと外部HTTPキャッシュ
App RouterについてはVarnish 7.7を前段に置いて外部のリバースプロキシでHTTPレスポンスをキャッシュできるか確認しました。App Routerを外部のHTTPキャッシュの背後に置いた場合にHTMLとRSCペイロードがどのように扱われるかを見るのが目的です。固定した内容を返すルートにはCache-Control: public, s-maxage=31536000を設定してLaravelからデータを取得するルートにはpublic, s-maxage=60を設定しています。
/static/1へHTMLをリクエストすると最初はMISSになり2回目はHITしました。/dynamic/1/1も最初はMISSで2回目はHITです。別のURLである/dynamic/1/2は改めてMISSになり、その次からHITしました。存在しない/dynamic/30/1001についても404のHTMLレスポンスが最初はMISSになり2回目はHITしました。
App Routerではクライアント側でページを移動するときなどにHTMLとは別にRSCペイロードも取得します。?_rsc=...が付いたリクエストもHTMLとは別にキャッシュされて最初のMISSのあと2回目はHITしました。
少し処理が必要だったのはURLに_rscが付いていないRSCリクエストです。今回使ったNext.js 16.4.0ではこのリクエストに対して?_rsc付きURLへの307リダイレクトが返りました。このリダイレクトが同じURLのHTMLキャッシュを置き換えないようVarnishではこの場合だけキャッシュを通さずNext.jsへ直接渡しています。
sub vcl_recv {
if (req.http.RSC && req.url !~ "[?&]_rsc(?:=|&|$)") {
return (pass);
}
}リダイレクト後の?_rsc付きRSCペイロードは通常どおりキャッシュできます。
この構成で使っているのはNext.js自身のData CacheやCache Componentsではありません。Next.jsが返したHTTPレスポンスをVarnishがCache-Controlに従って保存しているだけです。App Routerを使っていてもNext.jsが返すのは通常のHTTPレスポンスなのでPages Routerと同じようにアプリケーションの外側でキャッシュできます。今回確認した範囲ではHTMLとRSCペイロードのどちらもその対象にできました。
余談になりますがRSCにどこまで価値を感じるかはApp Routerへ移行するかどうかだけでなくReact Routerへ移行する場合にも関わってきます。React RouterチームはReact Router and React Server Components: The Path Forwardで将来のFramework ModeをReact Router RSCの上に構築する方針を示しており、現在のFramework Modeを利用しているアプリについては移行作業なしで切り替えられることを目指しています。そのためRSCを積極的に利用する価値を感じないことをApp Routerへ移行しない理由の一つとする場合でも、React Routerへ移行すればRSCを基盤とした構成から離れられるわけではありません。
検証結果から判ったこと
Dockerイメージサイズについては今回の比較でNext.jsがReact Router Framework Modeより何倍も大きくなるという結果にはなりませんでした。React Router Framework Modeが66.6 MiBだったのに対してNext.js 16 + Turbopackは71 MiB前後です。それより大きく影響したのはoutput: "standalone"の有無で、standaloneを使ったPages Routerは71から75 MiB程度だったのに対して本番用のnode_modulesをそのままDockerイメージへ入れる構成では143から147 MiB程度まで増えました。next/imageを使わない今回のアプリでは不要なsharpを除外することでも約8.4 MiB小さくなっています。
ビルド時間についてはReact Router Framework ModeをVite/Rolldownでビルドした0.7秒が最も短い結果でした。Next.jsでは16 + Turbopackが大きく短縮される一方で15ではTurbopackがwebpackより長くなっています。Rspackもwebpackより短くなりましたがNext.js 16 + Turbopackほどの差にはなりませんでした。
外部HTTPキャッシュについてはVarnishを使った検証でApp RouterのHTMLとRSCペイロードをどちらも保存できました。
今回の結果ではDockerイメージサイズはoutput: "standalone"の有無や実際に必要な依存パッケージによって大きく変わり、ビルド時間は使うバンドラやNext.jsのバージョンによって大きく変わりました。さらに既存のwebpack向けカスタマイズを残しやすいRspackという選択肢もあり、React Routerも将来のFramework ModeをRSCの上に構築する方針を示しています。Dockerイメージサイズやビルド時間だけでなくキャッシュをどこで担うのか、既存の構成から何を変更する必要があるのかまで含めて考える必要がありそうです。
実装や10回分の測定結果、Dockerイメージの内訳、Varnishで確認した各レスポンスは検証用リポジトリに置いています。