Next.js から React Router に移行しました

  • URLをコピーしました!

こんにちは、フロントエンドエンジニアのやなぎ(@apple_yagi)です。

PR TIMESでは、2023年頃からプレスリリース掲載ページやトップページなどをNext.jsへ移行してきましたが、今回、そのNext.jsからReact Routerへの移行を行いました。その結果、既存の要件を維持しながら、ビルドやデプロイにかかる時間を大幅に短縮することができました。

この記事では、Next.jsからReact Routerへ移行した理由や移行方法、移行によって得られた効果、実際に発生した問題について紹介します。

目次

Next.jsを採用していた理由

2023年当時、Server Side Rendering(SSR)に対応したReactフレームワークとしてRemixなどの選択肢もありましたが、PR TIMESではRemixを利用した経験のあるエンジニアが少なく、他社での採用事例もNext.jsと比べて多くなかったことから、チームの知見や導入・運用のしやすさを考慮してNext.jsを採用しました。

PR TIMESにおけるNext.jsへの移行については、過去の記事でも紹介しています。

Next.jsをやめる理由

PR TIMESではNext.jsのPages Routerを使用していますが、近年のNext.jsではApp Routerを中心に機能追加が行われており、Pages Routerを使い続けるPR TIMESにとって、アップデートによるメリットを得にくくなっていました。

App Routerへの移行も検討しましたが、PR TIMESではgetServerSidePropsで生成したHTMLをFastlyでキャッシュする構成を取っているため、App Routerのキャッシュ機能やReact Server Componentsを利用するメリットは大きくありません。キャッシュをFastlyに集約しているのは、API経由でキャッシュを即時に削除できることに加え、キャッシュする層を1つにすることで挙動を把握しやすくするためです。

PR TIMESにおけるNext.jsとキャッシュの構成については、以前登壇した「PR TIMESにおける Next.js と cache の付き合い方」でも詳しく紹介しています。

また、開発体験にも課題がありました。現在の構成ではwebpackを使用しており、開発サーバーの起動やビルドに時間がかかっています。以前Turbopack(Next.jsで利用できるRust製の高速なバンドラー)への移行も検討しましたが、webpackから挙動が変わる箇所があり、移行を見送っていました。

このように、Next.jsのアップデートによる恩恵を受けにくい一方で、開発サーバーの起動やビルド速度といった課題がありました。そこで、今後Next.jsで配信するページを増やしていく前に、別のフレームワークへの移行を検討することにしました。

Next.jsからの移行先の選択肢

今回のフレームワーク選定・検証は2026年5月頃に行いました。検証時に使用した主なバージョンは以下のとおりです。

  • Next.js: v15
  • React: v18
  • React Router: v7
  • Vite: v8
  • TanStack Start: v1 RC

移行先を検討するにあたり、まず条件としたのがビルドツールとしてViteを使用できることです。

Viteはv8からRust製のバンドラーであるRolldownを使用しており、開発サーバーの起動やビルドの高速化が期待できます。webpackを使用している現在の構成と比較して、開発体験を改善できる点を大きなメリットと考えました。

もう一つの条件は、Reactを継続して使用できることです。

PR TIMESでは2021年からjQueryからReactへの移行を進めており、すでに多くのReactのコード資産があります。別のUIライブラリへ移行するメリットよりも、既存のコード資産を活用できなくなるデメリットの方が大きいと判断しました。

これらの条件から移行先として以下の2つを検討し、比較・検証した結果、React Routerを採用することにしました。

TanStack StartではなくReact Routerを選んだ理由

React Routerを選んだ主な理由は以下のとおりです。

  • TanStack StartがRC段階だった
    • 検討時点ではRCの段階であり、本番環境で採用するにはまだ時期が早い可能性があると考えました。
  • Portable Application Modelのメリットが小さかった
    • TanStack Startの特徴の一つに、さまざまな実行環境へのデプロイを可能にするPortable Application Modelがあります。しかし、PR TIMESではAWS ECSへのデプロイを前提としているため、このメリットを活かす機会は多くありません。
    • また、検証した構成ではサーバーサイドをNitroでビルドする必要があり、React Routerと比較してビルドに時間がかかりました。
  • TanStack Startのサーバー機能を必要としていなかった
    • TanStack StartにはcreateServerFnなどのサーバー機能がありますが、PR TIMESでは利用する予定がありません。
    • 今回必要としているのは主にSSRであるため、必要な機能をよりシンプルな構成で実現できるReact Routerの方が適していると判断しました。
  • React Routerの開発体制
    • React Routerは2025年からOpen Governance Modelへ移行し、プロジェクトの意思決定プロセスが公開されています。この点も、長期的に利用するフレームワークとして採用を後押しする材料になりました。
    • React Router Open Governance

以上の理由から、PR TIMESではNext.jsからReact Routerへ移行することにしました。

移行方法

コードの移行

Next.jsからReact Routerへのコード移行には、Claude CodeなどのAI Agentも活用しました。ただし、最初から移行作業をAI Agentに任せるのではなく、まず1ページを人の手でNext.jsからReact Routerへ移行し、その後、同じ方針でAI Agentに横展開させる方式を取りました。最初の1ページを人の手で移行したのは、Next.jsとReact Routerの違いや、移行時にどのような変更が必要になるのかを人が理解したうえでAI Agentを利用するためです。

PR TIMESでは、Next.js固有の機能への依存を避けるために、eslint-plugin-no-next-restrictedというESLintプラグインを使用してnext/imageなどのNext.js固有のAPIをリントレベルで制限していました。そのため、多くのページではgetServerSidePropsで行っていたデータ取得処理をReact Routerのloaderへ移すなど、比較的シンプルな変更で移行できました。

VRTによる自動テスト

PR TIMESでは以前からPlaywrightによるVisual Regression Test(VRT)を行っていました。

あわせて読みたい
PR TIMESにおけるPlaywrightを用いたVisual Regression Test こんにちは、フロントエンドエンジニアのやなぎ( @apple_yagi )です。 昨年、Integration TestツールをCypressからPlaywrightに移行しました。その際、Visu...

今回の移行ではNext.jsで取得していたスナップショットを正として、React Routerでも同じPlaywrightのテストを実行しました。これにより、フレームワークの移行によって意図せずUIが変わっていないかを自動的に確認できました。フレームワークの移行では、Unitテストだけでは検出しにくい、実際にSSRしたHTMLやCSSを含む表示上の差分が発生する可能性があります。そのため、実際にアプリケーションをビルドしてサーバーを起動した状態で実行するVRTがとても役に立ちました。

Next.jsとReact Routerで同じPlaywrightのテストコードを実行できるよう、Playwrightの設定ファイルで環境変数を読み込み、webServer.commandを切り替えるようにしました。以下は関連する設定のみ抜粋したコードになります。

import process from 'node:process';
import {devices, type PlaywrightTestConfig} from '@playwright/test';

const isReactRouter = process.env.REACT_ROUTER === '1';

const config = {
  webServer: {
    command: isReactRouter ? 'pnpm run start:react-router' : 'pnpm run start:nextjs',
    port: 3000,
  },
  use: {
    baseURL: '<http://localhost:3000>',
  },
} satisfies PlaywrightTestConfig;

export default config;

インフラの移行

React Router用のECS環境を構築し、FastlyのVCLを利用して段階的に移行しました。Fastlyからオリジンへ送るリクエストについて、randombool関数を使用してReact RouterのECSへ振り分ける割合を1% → 10% → 50% → 100%と段階的に増やしました。

sub vcl_recv {
#FASTLY recv

  if (!req.backend.is_shield) {
    if (req.url.path ~ "^/main/html/rd/p/([0-9]{9}).([0-9]{9}).html") {
	    // 1%リリース
	    // 10%にする場合は randombool(10, 100) に書き換える
      if (randombool(1, 100)) {
        set req.backend = F_prtimes_react_router;
      } else {
        set req.backend = F_prtimes_nextjs;
      }
    }
  }
}

Next.jsとReact Routerでは、同じURLに対して同じデザイン・内容のHTMLを配信するため、Fastlyのキャッシュは分けずに共有しています。そのため、ここで指定している割合はユーザーがReact Routerで生成されたHTMLを受け取る割合ではなく、キャッシュミスなどによってFastlyからオリジンへリクエストが送られる際に、React RouterのECSへ振り分けられる割合になります。

New Relicによる移行の監視

実際にユーザーがどちらのアプリケーションで配信されたページを閲覧しているかの確認には、以前から利用しているNew Relic BrowserのPageViewを使用しました。Next.jsとReact Routerにはそれぞれ異なるNew RelicのApplication IDを設定しているため、それぞれのPageView数を比較することで、実際のユーザー側での移行状況を確認しました。また、移行状況だけでなく、キャッシュヒット率などのメトリクスに変化がないかも確認しながら段階的に移行を進めました。

A New Relic dashboard displaying various metrics, including a percentage reading of 49.47%, average CPU usage graphs, CDN cache hit ratio, and data on containers with the highest memory usage.
実際に使用したNew Relicのダッシュボード

React Routerへの移行によって改善したもの

React Routerへの移行によって、特にビルド時間、Docker Imageのサイズ、デプロイ時間が改善しました。

ビルド時間

Next.js(webpack)では平均37秒かかっていたビルドが、React Routerでは平均3秒で完了するようになりました。

計測にはBlacksmithのrunnerである blacksmith-4vcpu-ubuntu-2404 を使用し、Next.jsの next build とReact Routerの react-router build をそれぞれ複数回実行しています。Next.jsではLintと型チェックをスキップし、 .next/cache を利用した状態で計測している一方、React RouterではViteのキャッシュを利用していません。このようにNext.js側が有利な条件で比較しても、React Routerへの移行によってビルド時間を大幅に短縮できました。

Docker Imageのサイズ

AWS ECR上で確認できる圧縮後のDocker Imageのサイズは、Next.jsでは231.76MBでしたが、React Routerへの移行後は92.69MBまで削減できました。

移行前後でDockerfileの構成や依存関係の配置、multi-stage buildなどは変更しておらず、Next.jsの依存関係をReact Routerへ置き換えただけで、Docker Imageのサイズを大幅に削減することができました。

デプロイ時間

Next.jsをECSへデプロイする際は6分46秒かかっていましたが、React Routerへの移行後は4分30秒まで短縮されました。

これはビルド時間が短縮したことに加え、Docker Imageのサイズが小さくなったことで、Imageの転送やECSタスクの起動にかかる時間が短くなったことも要因の一つと考えています。

React Routerへの移行で問題になったこと

一方で、Next.jsからReact Routerへの移行がすべてそのまま進んだわけではありません。実際に移行する中で、いくつかReact Routerに合わせた対応が必要になりました。

静的アセットの配信パス

Next.jsではJavaScriptやCSSなどの静的アセットが/_next/配下から配信されますが、React Routerではデフォルトで/assets/配下から配信されます。PR TIMESではPHPでHTMLを配信している箇所もあり、/assets/のような広いスコープのURLは極力使用したくありませんでした。

そこで、React Routerのサーバーをカスタマイズし、/_react-router/配下から静的アセットを配信するようにしました。

import {reactRouter} from '@react-router/dev/vite';
import {defineConfig} from 'vite';

export default defineConfig(({isSsrBuild}) => ({
  plugins: [reactRouter()],
  build: {
    // Next.js(/_next/static)と同様にプレフィックスを付与して出力する
    assetsDir: '_react-router/assets',
    // カスタムサーバ(server/app.ts)をSSRビルドの入力としてコンパイルする
    // @see <https://reactrouter.com/api/other-api/adapter#migrating-from-the-react-router-app-server>
    rollupOptions: isSsrBuild === true ? {input: './server/app.ts'} : undefined,
  },
}));

また、react-router-serveでは標準の/assets配下に対して長期間のキャッシュを設定していますが、今回は配信パスを変更しているため、その設定もカスタムサーバー側で行う必要がありました。

import {createRequestHandler} from '@react-router/express';
import express from 'express';

export const app = express();

// react-router-serveは`/assets`にimmutableキャッシュを付けるが、vite.config.tsで
// assetsDirを`_react-router/assets`に変更しているため、そのパスに合わせる。
// createRequestHandlerより前に登録し、build/client(maxAge: 1h)より先にimmutableを効かせる
app.use(
  '/_react-router/assets',
  express.static('build/client/_react-router/assets', {
    immutable: true,
    maxAge: '1y',
  }),
);

app.use(express.static('build/client', {maxAge: '1h'}));

app.use(
  createRequestHandler({
    build: async () => import('virtual:react-router/server-build'),
  }),
);

hydration mismatchエラーの多発

React Routerへの移行後、New Relic上でhydration mismatchに関するエラーが大量に発生していることがわかりました。

Error: Minified React error #418; visit <https://reactjs.org/docs/error-decoder.html?invariant=418>
for the full message or use the non-minified dev environment for full errors
and additional helpful warnings.

調査したところ、PR TIMESではChromeなどのブラウザ拡張機能によってheadタグ内などにstyleタグやscriptタグが挿入された際に、hydration mismatchが発生していることがわかりました。実際に、headタグ内にstyleタグを挿入するブラウザ拡張機能を有効にすることで、ローカル環境でも同様のhydration mismatchを再現できました。

また、New RelicのSession Replayで実際にエラーが発生しているユーザーの画面を確認しましたが、表示上の問題は確認できませんでした。今回の移行ではコンポーネントなどの実装は変更しておらず、Next.jsからReact Routerへフレームワークを切り替えたことで発生し始めたことから、React Routerのhydration方法の違いによって表面化したものと判断しました。

React RouterのFramework Modeでは、デフォルトで以下のようにdocument全体をrootとしてhydrationします。

// app/entry.client.tsx
import {startTransition, StrictMode} from 'react';
import {hydrateRoot} from 'react-dom/client';
import {HydratedRouter} from 'react-router/dom';

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
  );
});

そのため、hydration前にブラウザ拡張機能などによってheadやbody内のDOMが変更されると、サーバーが生成したHTMLとの差分として検出される場合があります。

現在PR TIMESではReact 18を使用していますが、React 19ではサードパーティースクリプトやブラウザ拡張機能によってheadやbody内に予期しないタグが挿入された場合、それらをスキップしてhydration mismatchを避ける改善が入っています。そのため、今後React 19へアップデートすることで、この問題を根本的に改善できる可能性があります。

あわせて読みたい
React v19 – React The library for web and native user interfaces

現時点では、New Relic上で大量のhydration mismatchによって他のエラーが埋もれることを避けるため、onRecoverableErrorを使用してconsole.warnとして出力するようにしています。onRecoverableErrorは、hydration mismatchなど、Reactが自動的に復旧できるエラー(recoverable error)が発生した際に呼び出されるコールバックです。

import {startTransition, StrictMode} from 'react';
import {hydrateRoot} from 'react-dom/client';
import {HydratedRouter} from 'react-router/dom';

startTransition(() => {
  hydrateRoot(
    document,
    <StrictMode>
      <HydratedRouter />
    </StrictMode>,
    {
      onRecoverableError(error, errorInfo) {
        console.warn(error, errorInfo.componentStack);
      },
    },
  );
});

onRecoverableErrorにはhydration mismatch以外のrecoverable errorも渡されるため、今回はそれらもすべてconsole.warnとして扱っています。

JSON-LDで複数の構造化データを配列で指定すると型エラーになった

移行を進める中で、React RouterのMetaを使って複数の構造化データをJSON-LDとして出力する際に、配列を指定すると型エラーになる問題がありました。

PR TIMESでは、1つのページに複数の構造化データを出力する箇所があります。しかし、当時のReact Routerではscript:ld+jsonの型が単一のオブジェクトのみを受け付けるようになっていたため、複数の構造化データを配列で指定すると型エラーが発生していました。

そのため、移行当初は次のように型キャストで回避していました。

export function pressReleasePageJsonLdMeta(): MetaDescriptor {
  return {
    'script:ld+json': [
      {
        '@context': '<https://schema.org>',
        '@type': 'Organization',
        name: 'Example Inc.',
        url: '<https://example.com>',
      },
      {
        '@context': '<https://schema.org>',
        '@type': 'BreadcrumbList',
        itemListElement: [],
      },
    ],
  } as unknown as MetaDescriptor;
}

実際のレンダリング処理ではscript:ld+jsonの値をJSON.stringifyしているため、配列を渡してもJSON-LDとして出力できます。そこで、script:ld+jsonの型がオブジェクトの配列も受け付けられるようReact Router本体を修正し、Pull Requestを送りました。

GitHub
Support array of objects for `script:ld+json` meta descriptor by apple-yagi · Pull Request #15082 · ... Summary Allow the script:ld+json meta descriptor to accept an array of LdJsonObject in addition to a single object, so a route can declare multiple JSON-LD sche...

Pull Requestがマージされたことで型キャストが不要になり、次のようにそのまま配列を指定できるようになりました。

export function pressReleasePageJsonLdMeta(): MetaDescriptor {
  return {
    'script:ld+json': [
      {
        '@context': '<https://schema.org>',
        '@type': 'Organization',
        name: 'Example Inc.',
        url: '<https://example.com>',
      },
      {
        '@context': '<https://schema.org>',
        '@type': 'BreadcrumbList',
        itemListElement: [],
      },
    ],
  };
}

React Routerへの移行では、このように既存の実装との差分で問題が見つかった場合、必要に応じてReact Router本体にも変更を加えながら対応しました。

各メトリクスの変化

React Routerへの移行によるユーザー体験やサーバー負荷への影響を確認するため、New RelicでNext.jsとReact RouterのWeb VitalsやECSのCPU・メモリ使用率を比較したところ、今回の計測範囲では大きな悪化は観測されませんでした。

Web Vitals

以下の画像は、Next.jsとReact Routerで配信しているすべてのページを対象に、Mobile/Desktopを分けず、それぞれ約1日分のWeb Vitalsを集計したものです。75パーセンタイル(p75)は、LCPがどちらも0.86秒、INPがどちらも90ms、CLSがNext.jsでは0.01、React Routerでは0となっており、移行前後でWeb Vitalsに大きな変化がないことがわかります。

Web vitals assessment displaying three metrics: Largest Contentful Paint (LCP) at 0.86s labeled as GOOD, Interaction to Next Paint (INP) at 90ms labeled as GOOD, and Cumulative Layout Shift (CLS) at 0.00 labeled as GOOD, with performance thresholds for each metric.
React Router Web Vitals
Screenshot of website performance metrics showing 'Largest Contentful Paint' at 0.86 seconds, 'Interaction to Next Paint' at 90 milliseconds, and 'Cumulative Layout Shift' at 0.01, all rated as good.
Next.js Web Vitals

CPU・メモリ使用率

以下の画像は、Next.jsとReact Routerの本番環境におけるCPU・メモリ使用率をそれぞれ計測したものです。React RouterはNext.jsと同じタスク数・同一スペックのECS Fargate環境で稼働しており、CPU使用率はどちらもおおむね20%台後半〜30%台前半で推移し、メモリ使用率はNext.jsが12〜13%程度、React Routerが10〜11%程度となっていることから、移行前後でCPU・メモリ使用率に大きな変化がないことがわかります。

Graph showing CPU usage percentages of various containers over the last 30 minutes, with time on the x-axis and CPU usage on the y-axis.
React Router CPU usage
Line graph showing the memory usage percentage of various containers over a 30-minute period, with time intervals marked on the x-axis.
React Router memory usage
Line graph displaying CPU usage (in percentage) of various containers over a 30-minute period, with time indicators from 2:55 PM to 3:20 PM.
Next.js CPU usage
Line graph showing memory usage percentage of containers over time, from 2:55 PM to 3:20 PM, with varying usage levels indicated by different colored lines.
Next.js memory usage

まとめ

PR TIMESではNext.jsからReact Routerへ移行し、Web VitalsやCPU・メモリ使用率をほぼ同じ水準に保ちながら、ビルドやデプロイにかかる時間を短縮することができました。React Routerへの移行を比較的スムーズに進められた理由の一つは、PR TIMESのフロントエンドがSSRを中心とした構成で、既存のReactコードをそのまま活用できたことです。また、以前からNext.js固有のAPIへの依存を抑えていたため、Next.jsから切り離す際に必要な変更も限定的でした。

エンジニアからはTanStack Startを使いたいという声も多くありましたが、プロダクトの要件や長期的な運用を考えてReact Routerを選択しました。今回の移行を通して、新しい技術を採用すること自体を目的にするのではなく、プロダクトに必要な要件や既存の資産を踏まえて技術を選ぶことの大切さを改めて感じました。

  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発本部 フロントエンドエンジニア

目次