一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

一休レストランで Next.js App Router から Remix に乗り換えた話

このエントリーは一休.com Advent Calendar 2023の15日目の記事になります。


CTO 室の恩田です。

現在は一休レストランのフロントエンドのリアーキテクトを手がけています。 今日はその中で Next.js App Router から Remix に乗り換えた話をご紹介したいと思います*1

背景

6日目の記事で香西から紹介させていただきましたが、2023年10月に一休レストランのスマートフォン用レストラン詳細ページをリニューアルしました。

あらためてリニューアルでの技術的な変更点を再掲すると:

  • バックエンド言語:Python から Rust へ
  • フロントエンドフレームワーク:Nuxt v2 から Next.js App Router へ

つまり、このエントリは先日リリースしたばかりの Next.js から Remix に乗り換えた、という話になります。

図らずも、昨今盛り上がっている Next.js 論争*2に足を踏み入れることになりました。

Next.js App Router について

まずは disclaimer として、あくまで一休レストランにおいて Next.js App Router が "not for us" であっただけで Next.js そのものに対する評価ではないことは申し添えておきます。

その上で、ここでは Next.js App Router を採用した経緯と、実際に採用してみてどんな課題に遭遇したのかを簡単に説明したいと思います。

当初 Next.js を採用した経緯

採用を決めたのは Next.js 13 の発表直後、一休レストランのリニューアル計画が動きはじめた頃になります。

以下が主に評価した点ですが、

  • メタフレームワークとしてデファクトスタンダードとしての地歩を固めつつあったこと
  • 弊社内の別プロダクトで Next.js (Pages Router) の採用実績が複数あること
  • そして toC サービスである一休レストランにとって、カリカリにチューニングできそうな React Server Component が非常に魅力的なフィーチャーであったこと

特に最後の React Server Component が採用の決め手となりました。

先日の Next.js 14 で発表された Partial Prerendering もそうですが、toC サービスの欲しい機能をピンポイントに突いてくるニクいフレームワークです。

Next.js の Pain Points

そもそも今回のリニューアルにおけるビジネス上のゴールは、一休レストランで予約するとき、お店に電話をかけたときのようなスムーズな体験を提供する、というものでした。

しかし、社内レビューや canary release の過程で見つかったユーザー体験の問題を改善するにあたって、Next.js App Router では実現が難しそうな課題がいくつか見つかってきました。

History API の state を触れない

リニューアルしたスマートフォン版一休レストランは以下のような画面遷移になります。

レストラン詳細ページ

空席確認カレンダーモーダル

人数・日時を選択する空席確認カレンダーのモーダル表示がポイントです。*3

ここでの選択は予約にいたるまでの一連の流れのワンステップなので、操作中はブラウザの「戻る」やリロードで開いた状態を維持したいモーダルです。

ただ、その状態で URL が LINE などで共有されたときは、モーダルのない詳細ページが開いて欲しい場面でもあります。

Next.js App Router の Link コンポーネントや useRouter フックでは History API の state を操作することはできず、URL を変更せずにブラウザ履歴を積んだ上で画面表示を変更することができません。

Cache-Control ヘッダを自由に設定できない

Next.js App Router では Cache-Control ヘッダは Dynamic Functions が利用されたかどうかと Route Segment Config で設定した値を元に Next.js 自身が出力する仕様となっており、利用者が自由に値を設定することはできません。

例えば searchParams を参照しただけで Dynamic Functions と判定され、強制的に Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate が出力されてしまいます。

Fastly を CDN として利用している一休では、Cache-Control ヘッダを制御できない*4という制限は、パフォーマンスやインフラ負荷に影響を与える大きな問題です。

また、レストラン詳細ページ以降のページだけが今回のリニューアル範囲のため、 bfcache が無効になってしまうのも、既存ページとの遷移でユーザー体験に悪影響を及ぼします。

継続的なアップデートに懸念を覚えた

Next.js のパッチバージョンを上げたときに production build でだけ 500 エラーが発生するという問題に幾度か苦しめられました。

App Router で運用している世界の様々なサイトで同じ問題が発生していたら大きな Issue になっているはずで、一休レストランのコード、もしくは利用ライブラリのいずれかに原因があったことには間違いないとは思います。

現象の再現状況の特定が難しく、加えて調査に十分なリソースを割けなかったという背景もありましたが、正確な原因が掴めず仕舞いとなってしまったことには歯痒い思いとともに、懸念が残りました。

Remix への乗り換え

上記の課題を解決するため、最終的には Remix に乗り換えることを決定しました。

Remix を採用した理由

Next.js App Router で抱えていた課題の裏返しになるのですが、そもそもの Remix の設計指針である、Web 標準 API を尊重している点*5を特に重視しました。

History API

改善したかったクライアントサイドのナビゲーションを例に取ると、Remix の提供している Link コンポーネントや useNavigate フックは History API *6 の薄い wrapper になっていて state を利用することが可能です。

具体的には、Remix 自身もスクロール位置の維持をはじめとするクライアントサイドナビゲーションの管理に History API state を利用していて、Remix API で利用者が指定した stateHistory API state では、

{
  "usr": {"state": ["set", "from", "Remix API"]}, 
  "key": "dgfkntlh", 
  "idx": 2
}

上記の例のように Remix が定義する History state の構造の中の "usr" キーの中に格納されます。

この構造を理解していれば、直接 History API replaceState を呼ぶことで Remix の遷移は抑止しつつ state だけを置き換えるような運用も実現できます。

Cache-Control ヘッダ

Next.js Pages Router の getServerSideProps に相当する Remix の機能に loader があります。

loader の引数や返り値は Web 標準の Request / Response なので Cache-Control にも出力したかった値を設定でき、CDN やブラウザキャッシュをコントロールする自由を取り戻しました。

その他

他にも Next.js App Router の Async Server Component に相当する効果*7が得られる defer など、toC サービスである一休レストランにとって魅力的な機能を備えています。

検討した代替案

Remix 以外に検討した対策についても簡単にご紹介します。

Next.js に patch をあてる

Cache-Control ヘッダの問題は Next.js の設計方針そのものでどうしようもないので、 pnpm patch でヘッダを出力している Next.js の当該コードを上書きしてしまう対策*8も試しました。

ですが Cache-Control を制御したい path が増える度に patch を更新するのは手間がかかって煩わしいし、ヘッダを書き換えられるようになるだけで、ナビゲーション問題は解決できません。

Pages Router への切り替え

Pages Router への切り替えも少しだけ検討しました。

一休の他プロダクトで Pages Router の実績はあるので安定性に不安はありませんが、React Server Component に期待したパフォーマンス面はあまり期待できそうにありません。*9

また Vercel の開発リソースも App Router にほぼ向けられているだろうし、現時点において Pages Router を選択するのは将来性も見込めないと判断しました。

Remix 置き換えで得られた効果

ちょうど Remix 版をリリースして一週間経過したところですが、以下のような効果が得られています。

継続的なアップデート

2023-12-18 追記

つい先日の 12/14 にリリースされたばかりの Remix 2.4.0 まで、問題なく追随できていることをご報告しておきます。

Fastly の cache hit ratio が 63% → 68% に

置き換えの目的の内の一つである CDN とブラウザキャッシュの有効活用です。

背景で紹介していますが、リニューアル対象はスマートフォン用のレストラン詳細ページ以降のみで、一休レストラン全体から見れば、ごく限られた範囲でしかありません。

にも関わらず、一休レストラン全体の cache hit ratio を 5% ポイント近く向上させることができました。

インフラの効率化もさることながら、Fastly のキャッシュから返ってくるときのレスポンス速度は圧倒的に高速なので、ユーザー体験を向上させる改善に繋がったことが何よりも嬉しい成果です。

Cloud Run の効率化

ここは意図していませんでしたが Remix 乗り換えで得られた嬉しい副作用です。

メモリ使用量が 1/4 に

Cloud Run Memory Utilization

グラフの通りメモリ使用量が 1/4 に減りました。 一休レストランは夕方から夜にかけてアクセスのピークを迎えるのですが、その間も安定して同じ水準を保っています。

コンテナ起動時間が 1/2 に

Cloud Run Startup Latency

Next.js では 20 秒強かかっていたコンテナ起動時間が 10 秒に縮まりました。

Next.js 時代からの課題ですが、ローカルでは一瞬で起動するのに、Cloud Run だと起動に時間がかかってしまう問題は調査中です。

所感と最近の議論

Remix に乗り換えての個人的な所感になりますが、Web 標準 API がそのまま使えて、利用者が思った通りにコントロールできる非常に扱いやすいフレームワークだと感じています。

上記はあくまで私の印象になるので、最近の Next.js の議論で特に参考にさせていただいたリソースを紹介します。

今後の展望

現時点ではまだ Remix に置き換えただけで、ようやく改善のための足回りが整った、という段階です。

引き続きよりよいユーザー体験を目指して、本丸のナビゲーションの改善、CDN キャッシュ効率向上によるレスポンスの高速化を進めていきたいと思います。

おわりに

今回の一休レストランの問題だけでなく、フロントエンド領域で難しい課題をまだまだ抱えています。

一休では、事業の成功を技術面からともに支える仲間を募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

hrmos.co

*1:同じ一休レストランフロントエンドのリアーキテクトの一環で XState を導入した話は22日目の記事でご紹介しています。

*2:後段で紹介します。

*3:カレンダーの状態管理についての紆余曲折については22日目の XState の記事で紹介しているので、ご笑覧いただければ幸いです。

*4:Fastly のキャッシュ制御は Surrogate-Control ヘッダで、ブラウザキャッシュのための Cache-Control ヘッダは VCL など他の手段で上書きすることはできますが...

*5:Remix サイトのトップページに "Focused on web standards and modern web app UX" と掲げられています。

*6:Navigation API が早く普及して欲しい...

*7:正確に述べると fetch 処理は loader に一元化して Promise を defer を使って返す必要があります。

*8:この問題は他の利用者も困っているようで Next.js の Issue 内に patch をあてる workaround が紹介されています。

*9:Remix 公式ブログの Next.js との比較記事 で詳解されていますが Pages Router と比較すると Remix に軍配があがるようです。