グローバルなレストラン予約サービス、AutoReserveの開発をしているjavascripterです。
今回は、ハローでのautoreserve.comのアーキテクチャの変遷についてお話しします。
概要
AutoReserveは最初Next.jsで構築され、その後SPAに移行し、最終的に再びNext.jsに戻るという珍しい技術選択をしています。
この記事では、各アーキテクチャの移行の背景と、大規模アプリケーションの段階的移行の実践について解説します。
AutoReserveについて
AutoReserveは世界中のレストランの予約が可能なグローバルサービスです。iOS・Android・Webの3プラットフォームで展開しており、各アプリ100ページ近くある大規模なアプリケーションです。
モバイルアプリはReact Native (Expo)で開発しており、当初のWeb版はNext.jsで構築していました。その後、開発効率化のためSPAに移行し、最終的にNext.jsに戻すという変遷を辿っています。
React Router製のSPAへの移行
立ち上げ期のAutoreserveでは、専業のフロントエンドのエンジニアが自分一人であったため、開発効率の最大化が重要な課題でした。
初期はNext.js製アプリとして開発していたWeb版ですが、モバイルアプリとのコード共有を実現するため、早期にReact Router製のSPAアプリへと移行することを決断しました。以下に、この技術選択の背景と実装について説明します。
SPA移行の背景
Next.jsで開発をする上で、主に直面した問題は下記です。
- プラットフォームごとにコードを書くことの開発・メンテナンスコスト
- 既存ネイティブアプリはSSRに必要な対応(ユーザ間での状態の分離、CSSによるレスポンシブ対応)がされておらず、単純な書き換えが困難
採用した解決策
限られたリソースで3プラットフォームを同時開発するため、以下のような技術スタックとアーキテクチャを採用しました。
※ 詳細については過去の記事を参照ください。
- React Router製のSPAアプリケーションへの移行
- react-native-webを活用し、ネイティブアプリのコードを最大限再利用
- SEO対策としてHeadless Chromeによるダイナミックレンダリングを採用
スケーリングの課題
SPAへの移行とReact Native Webの採用により、コードの大部分を共通化することができ、3プラットフォームの同時開発を行うことができるようになりました。
しかし、Web版が成長しユーザー獲得の主要チャネルとなるにつれ、以下の課題が顕在化しました:
パフォーマンスの問題
- Dynamic Renderingによる1秒以上のオーバーヘッドがある
- react-native-webによってバンドルサイズが増大している
- Googleのクローラーバジェット不足によるインデックスの限界がきている
技術的制約
- レスポンシブ対応が困難
- SSR非対応などWebとして最適なコードにできない
Next.jsへの再移行と、Webファーストへの進化
レストランメディアという性質上、AutoReserveではページ全体での検索トラフィックが成長の鍵となります。 Webプラットフォームが成長するにつれて、SEOの重要性が増している中、スケール上の課題に直面しました。
グローバル展開にともなって、「国 x その国のレストラン数 x 対応している言語数」のページが存在するため、クローラが回りきらなくなるという問題です。
膨大に存在するページをGoogleに認識してもらうためには、クロールバジェット(Googleが各サイトのクロールに割り当てる時間)内に、より多くのページをクロールしてもらう必要があります。 ページスピードを上げる必要がありますが、SPA + Dynamic Renderingでは技術的制約により高速化には限度があります。
そこで、開発効率の向上として採用したモバイルアプリとのコード共有という制約を外し、Webに最適化されたアーキテクチャへの移行を決断しました。以下に、この移行プロジェクトの詳細を説明します。
移行の目標
以下の3点を主要な目標として設定し、アーキテクチャの再設計を行いました。
- SEO最適化とパフォーマンスの大幅な改善
- レスポンシブ対応などの技術的負債・制約の解消
- 今後のサービスのスケールに柔軟に対応できる基盤の整備
Next.jsの選択の理由
以前使用し辞めた経緯のあるNext.jsですが、既存コード資産の再利用という制約を除いた上で最適なフレームワークを検討した結果、再度採用することとしました。理由は下記です:
- SEO最適化のデファクトスタンダードであり、実績がある
- SSR/SSG/RSCなど多様な最適化手法の選択肢があり、より最適な設計が判明した時に、フレームワークが制約となる可能性が少ない
段階的移行戦略
100画面ほどある大規模アプリケーションの移行は簡単ではなく、技術基盤を段階的に移行するためのアプローチが必要でした。
開発を止めることなく、かつリスクを最小限に抑えながら移行を実現するため、以下のような戦略を採用しました。
1. Next.js内でのReact Routerアプリの統合
AutoReserveではmonorepoを採用しており、元々は下記の構造になっていました:
apps/ web/ - 既存Web版アプリ ar_native/ - 既存React Nativeアプリ packages/ ar_app/ - 共通開発基盤
ウェブ、ネイティブそれぞれのアプリケーションから、機能開発を行う共通パッケージをimportして表示しているという構造です。
今回の再設計を行う上での主要アイディアはapps/web
という独立したReact Routerのアプリケーションは、<App />
をレンダリングする単一のコンポーネントを表示するライブラリとみなすことができる、という点です。
この観点に従って、apps/webはpackages/legacy_webに移動しました。
apps/ ar_web/ - 新規Next.jsアプリ (catch allルートでpackages/legacy_webをレンダリング) native/ - 既存React Nativeアプリ packages/ ar_app/ - 共通基盤(apps/native, packages/legacy_webから使用) legacy_web/ - 既存SPAアプリ(apps/webから移動)
2. モーダルページの統合
厄介な点として、AutoReserveでは、ログイン・登録のモーダルなど、他のページの上に表示されるモーダルのページが多数存在します。
旧アプリのうち、モーダルとして表示されるページは、catch allのrouteではなく、Parallel RoutesとIntercepting Routesを使い、個別に定義したページからpackages/legacy_web
を表示することで、新アプリのページの上から旧アプリのモーダルを表示できるようにしています。
これにより、新規ページ単体をロードしたときは、旧アプリは一切読み込まれることがないため、パフォーマンスへの悪影響を避けることができます。
参考:
app/ restaurants/ [slug]/ page.tsx - 新ページ [[...path]]/ page.tsx - 旧ページを(全画面で)表示するcatch allページ @modal/ (.)sign_in/ page.tsx - 旧ページをモーダルとして表示するためのページ
まとめると、下記のようになります:
- 新規ページ:Next.js App Routerをフルに活用し、最適化
- 既存ページ:catch-all routeでlegacy_webをレンダリング
next/dynamic
で動的な読み込みを行う
- 新規ページと既存モーダルの共存
- 新規ページの上に旧アプリのモーダルを動的に読み込む
ルーティングの技術的詳細
React RouterとNext.jsを同時に使用する上で、最も技術的な困難が伴ったのがルーティング周りの状態の共有・同期です。複数のルーティングライブラリが同時に混在することで、以下のような課題に直面しました。
1. クロスフレームワークの遷移
// Next.jsのページからReact Routerのページへの遷移 // catch-all routeにマッチするため自然に動作 import { useRouter } from 'next/navigation' useRouter().push('/legacy_web_page') // React RouterのページからNext.jsのページへの遷移 // React RouterのuseNavigateでは404エラーとなる import { useNavigate } from 'react-router' const navigate = useNavigate(); navigate('/new-nextjs-page'); // 404
2. 履歴管理の不整合
ブラウザバックでの遷移時、Next.jsは状態を更新するが、React Routerは独自に内部のhistory状態を持っているため、検知できないという問題がありました。
採用した解決策
クロスフレームワークの遷移
新規ページへの遷移はReact Router内でもNext.js側のuseRouter()を使用するように修正しています。
履歴管理の不整合
React Routerのhistory管理のライブラリhistoryをforkしコードを書き換え、Next.js側のページ遷移時に状態が同期されるようにパッチを当てることで解決しました。
具体的には、history.pushState, history.replaceStateなど、Next.js側のSPAでのページ遷移により呼び出される関数をmonkey patchし React Router側の内部のhistory状態を更新し再同期すると言う方法をとっています。
その他
遷移周りでは、React Router側のページ操作によりNext.js側のUIを更新することも必要です。
- 旧アプリからのログイン後、Next.js側のページをログイン状態にする
- モーダルで操作を行った後にモーダルを閉じたら、元のページのUIを更新する
このようなケースでは、React RouterのページがunmountされたタイミングでNext.jsのrouter.refresh()を呼び出すようにしています。
Next.js以外に採用した主要ライブラリ
余談にはなりますが、再設計にあたって技術選定も1から行ったため、採用に至ったライブラリも紹介します。
- react-aria-components
- Adobeで使用されているUIライブラリ
- アプリケーション全体でのベースのコンポーネントとして採用
- 再設計にあたってのアクセシビリティの改善も行いました
- stylex
- Facebook/Instagramで使用実績のあるCSS in JSライブラリ
- Atomic CSSの使用によるパフォーマンス・型安全なコードによるメンテのしやすさから採用
- Next.jsへの対応が不十分だったため、筆者がPostCSS Pluginを開発し、現在公式のプラグインとなっています
- @internationalized/date
- 日付操作を行うライブラリ
- グローバルサービスでタイムゾーン対応が重要であるため、タイムゾーン対応が充実しているこのライブラリを採用
移行の成果
4ヶ月に及ぶ移行プロジェクトを経て、当初の目標を大きく上回る成果を得ることができました。特筆すべきは、既存機能を維持しながら、パフォーマンスとSEOの両面で大幅な改善を実現できた点です。以下に具体的な成果をまとめます。
技術面での改善
- Next.jsとReact Routerのシームレスな統合によるスムーズなページ遷移の実現
- レガシーコードの分離により、新規ページへ技術負債を持ち込まず、SEO上の悪影響をゼロにする
- 単一のデプロイフロー維持による運用効率の向上
パフォーマンスの向上
- Core Web VitalsのLCPが平均1.7倍高速化
- キャッシュを切った状態でのクローラに対してのページロード時間が2.8倍ほど高速化
学んだこと
この移行プロジェクトを通じて、技術選択とアーキテクチャ移行に関する重要な教訓を得ることができました。
アーキテクチャ選択の本質
1. コンテキストの重要性
- 技術選択に「正解」はなく、チームの規模やプロダクトのフェーズによって最適解は変化する
- 立ち上げ期の効率重視から、スケールフェーズでの性能重視へと要件は変化する
2. 段階的アプローチの有効性
- 大規模な移行は、一度にすべてを書き換えるのではなく、段階的に進めることが重要
- 既存機能を維持しながら、パフォーマンスを改善できる移行戦略の設計が鍵
3. 技術的な挑戦の価値
- コアライブラリのforkなど、一見ハイリスクな選択肢でも、十分な検証と明確な方針・技術力があれば有効
- 「できない」と思われる制約も、視点を変えることでエレガントな解決策を見出せる
終わりに
ハローでは、プロダクトの技術的ニーズに向き合い、創造的な解決策を見出せる本物のエンジニアを募集しています。
少しでも気になる方は気軽に javascripter にDMでお声がけください!