RailsアプリのフロントエンドをじわじわとNext.jsにリプレースした話と、その振り返り

株式会社High LinkのCTOをやっている nogaken (@nogaken1107)です。

最近はChatGPTなどのLLM系のアプリケーションを触って楽しんでいます。

ハイリンクでは「カラリア 香りの定期便」などのサービスを開発しています。

カラリア 香りの定期便」は2021年まで、フレームワークとしてはRuby on Rails (以下Rails)単体で書かれていましたが、デザインリニューアルと合わせて2021年前半から1年間強の時間をかけてフロントエンドをNext.jsにリプレースしました。

結果として開発体験が向上し、気軽に実装できるデザインの幅が広がり、エンジニアの採用面でもメリットが得られました。

この記事では、カラリアのフロントエンドリプレースの背景、技術選定、リプレースのフロー、課題と、リプレース全体の振り返りについて紹介します。

現在、RailsでWebアプリケーションを開発していてNext.jsなどのモダンフロントエンドにリプレースしていきたい方々の参考になれば幸いです。

フロントエンドのリプレースに至った理由

デザインリニューアルについて

カラリアは2018年から開発がはじまったサービスで、初期のころは業務委託のエンジニア・デザイナーそれぞれ1名ずつで開発されていました。

定期便の登録者も増えてきて、ここからもっとグロースしていくぞ、という機運になった2021年のはじめ、以下のようなデザイン面の課題がありました。

  • PMF前にエンジニアがデザイン含めて作ったページがあり、サイト全体のUIに統一感がない
  • 香水を扱うという関係上、取り扱いブランドのブランドイメージを損なわない、作品に対するリスペクトを表現できるサービスにしたい

そのような背景のもとサイト全体の見た目をリニューアルするという意思決定をしました。

なぜフロントエンドをリプレースすることにしたか

当時、カラリアのフロントエンドはRails上でjQueryメイン、ほんの一部Vue.jsという構成になっていました。

サイト全体のデザインをリニューアルするだけなら、フロントエンドをRailsから剥がしてNext.jsなどのフレームワークにリプレースするのはマストではありません。

その上で、以下のような理由でモダンなフロントエンドに置き換えたいと考えました。

  1. 香水診断のような、香り選びをサポートするリッチな体験をスピーディに提供したい
  2. スタートアップにとってエンジニア採用はボトルネックになるので、候補者から見て開発者体験の良い技術スタックにしたい

上記背景により、デザインリニューアルとともに、フロントエンドをモダンな技術スタックにリプレースすることを決定しました。

フロントエンドの技術選定

フロントエンドの技術を選定する上での要件は以下でした。

  1. 採用面で魅力的な技術スタックにしたい
  2. エンジニア社員はバックエンド経験がメインで、フロントエンド技術に長けたメンバーがいなかったため、Zero-configなどのフレームワークの恩恵を得たい
  3. ECサイトである以上SEOの重要性は高く、動的なOGPの設定も行いたいのでSSRは必須

上記を要件とし、以下の技術を選定しました。

  • 言語: TypeScript

型安全性の恩恵を得るためにTypeScriptを選定しました。

TypeScript初心者で無知であったこともあり、コンパイラオプションについては noImplicitAnystrictNullChecks の設定をせず、すべてデフォルトのfalseの状態ではじめました。

後述の振り返りにも記載しますが、コンパイラオプションの設定については最初からstrict: trueではじめるべきだったと思います。いまでは大体のフラグをtrueに変更済みです。

  • フロントエントライブラリ: React

独自のテンプレート文法を覚える必要がなく、JSを知っていれば書ける、という点やシンプルさを重視する設計思想が、ソフトウェア開発に親しみがある人であればキャッチアップがしやすく感じたというのと、

2021年に技術選定をする際、フロントエンドコミュニティでReactのほうが勢いがあり伸びている点や、欧米ではすでにReactのほうが使われていたため選定しました。

Zero-configで開発がはじめられる点や、SSRをページ単位で指定できて簡単に実装できる点からNext.jsを採用しました。

デザインリニューアルに際して、サイト全体に統一感をもたらし使いやすいサイトにする、という目的のためにデザインシステムを導入し、スタイルに制限をかけたいと言う気持ちがありました。

独自の自分たちで1から作るのと比較して労力少なくデザインシステムを作れるという点で自分たちのやりたいことにマッチしていたため選定しました。

上記選定によって、カラリアのフロントエンドを、Next.js + TypeScript + Tailwind CSSにリプレースすることが決まりました。

バックエンドの技術選定

Next.jsへのリプレースに伴って、新しくAPIを開発する必要があります。

APIの方式としてはJSON形式のREST APIを選定しました。GraphQLも選択肢としてはありましたが、フロントエンドで新しい技術に挑戦する関係上、バックエンドは扱いに慣れた方式に留めたほうが無難という判断で、自分含む開発メンバーが慣れ親しんだREST APIにしました。

RailsREST APIを実装する際、オブジェクトをJSONに変換するJSONリアライザーのgemを使用するのが一般的です。

また、認証処理もAPI化する必要があるため、そちらもgemを使用しています。

それぞれのgemは以下を選定しました。

RubyまたはRailsAPIを開発する際に用いるシリアライザーgemはデファクトスタンダードと呼ばれるほどのものはなく、jbuilder, jb, ActiveModelSerializers複数の有力候補が存在しました。

リニューアル最初期は最も使用実績の多いjbuilderを使おうとしましたが、検証の段階でパフォーマンス上の問題が顕著で厳しいと判断し、利用ケースも多くドキュメントも充実しているActiveModelSerializersを採用しました。

ただ、最終リリースが2019年でありメンテナンスに不安があります。いまから技術選定するならば Alba がメンテナンスが活発で、かつドキュメントも整備されているのでおすすめです。カラリアのシリアライザーもそのうち徐々にAlbaに置き換えていくかもしれません。

カラリアはリニューアル前から認証の実装にdeviseを利用しており、それをAPI化するにあたって追加でdevise-token-authを導入しました。詳細は後述のリプレースの課題で説明します。

リプレースのプロセス

段階的か、一括か

リプレースの方法については、ページごとにリプレースしていくやり方をとりました。

これに対して一度のリリースで全てのページをリニューアルするというやり方もありますが、

  1. リプレース中も並行して、新規機能の追加を行なってユーザー体験の向上をしたかった
  2. 大きく一度に変えることによるビジネス的な影響を抑えたかった

という二点の理由により段階的なリプレースを選択しました。

デメリットとしては、デザインリニューアル前とリニューアル後のページがサイト中に混在することになるため、リプレース期間中の体験の一貫性が損なわれるというものが考えられましたが、デザイナーのデザインの工夫でページを行き来してもそこまで違和感のないデザインにできたためこのデメリットは軽減できました。

段階的なリプレース方法

カラリアはAWSのECS Fargate上で稼働しています。ECSの一つのタスク中にNginxとRailsのコンテナがそれぞれ配備されており、ロードバランサー (ALB)からのリクエストをNginxのコンテナが受け付けて、それをRailsにルーティングする、という構成でした。

これに加えて、Next.jsのコンテナを同タスク中に追加し、Nginxのlocationディレクティブでリプレース済みのページをNext.jsにルーティングすると言うやり方をとりました。

Next.js追加前と追加後のルーティングを図にしたものが以下です。

Next.jsのコンテナを別のタスクとして切り出し、リプレース前と後のページのルーティングを前段のロードバランサーで行うやり方も考えられますが、

  • ルーティングの設定もコードと合わせてコードレビューしたい
  • ただ、リニューアル時点ではAWSリソースがTerraformなどのIaC技術でコード化されておらず、コードで表現するためにはNginxの設定ファイルでルーティングをしてしまうのが早い

と言う理由で、上記方法を選択しました。

リプレースする上での課題

Next.jsへのリプレースをしていくにあたって、以下の点が事前の課題として挙がりました。

CSRF対策

RailsはデフォルトでCSRF対策が組み込まれているため、あまり意識することはありませんが、バックエンド処理をAPI化する際には注意する必要があります。

RailsにおけるCSRF対策については、こちらの記事 で詳細に紹介されています。

カラリアでは、CSRF Tokenを返すAPIを用意し、それをページ読み込み時に呼び出し、更新系のリクエストのヘッダーにトークンを付与すると言うやり方でCSRF対策を行いました。

class CsrfTokensController < Api::User::ApplicationController
    def show
      render json: { token: form_authenticity_token }
    end
end

認証

カラリアはユーザー認証の実装に devise を利用しており、cookieを利用してユーザー識別を行なっています。

これをAPI化するにあたって、devise-token-auth (以下DTA) を選定しました。これはdeviseに依存して実装された、認証APIを提供してくれるgemです。

デフォルト設定ではtokenをHTTPヘッダーにセットする方式ですが、設定によってcookieを使った方式にすることもできます。

注意すべきポイントとしては、DTAをcookieを使った方式にしたとしてもdeviseで付与されるcookieとの互換性がない点です。そのためdeviseからDTAに移行する際、何も考えずにリリースすると、既存のセッションは失われます。 (新ログインページで何か問題が起きてしまった場合、ロールバックすると戻すときにもう一度、再リリースするときにさらにもう一度セッションを失います。)

そこで、不要なセッションの喪失を抑えつつ、既存のページの大幅な実装変更を抑えるため

  1. DTAのconfigで enable_standard_devise_supportをtrueにする
  2. DTAのログインAPIで、認証成功時にDeviseの認証処理 (bypass_sign_in)もするようにする

という対応を取りました。

1の enable_standard_devise_support はDTA支配下APIでユーザー識別する際にDeviseのsessionで識別できればそちらを優先するという、deviseからDTAへの移行に適した設定です。こちらは試験的な機能としてドキュメントに記載されていますが、Deviseからの移行が終了したら使わずに済むのと、開発環境での検証で問題がなかったので採用しました。

これにより、deviseの旧ログインページで過去にログインしたユーザーが新しいページでもセッションを維持できます。

2によって、旧ページのユーザー識別処理に手を加えることなく、新ログイン画面でログインしたユーザーが旧ページでもセッションを維持できます。また、新ログイン画面でログイン済みユーザーが存在するとき、ログイン画面をロールバックしてもセッションを維持できます。

これにより、旧ログインページ (with devise)でログインしたユーザーは新ページでも認証状態を維持できますし、新ログインページ (wiht DTA)でログインしたユーザーは旧ページでも認証状態を維持できます。

結果として、ログインページをリプレースしたり、ロールバックしたとしてもユーザーのセッションを失わずに済むので、リリースの際の心理的負担も軽減されました。

リプレース期間中に発生した問題

上記の事前にわかっていた課題以外にも、リプレースを進行する中で発生した問題もありました。

<Link>の使用による意図しない新ページの露出

Next.jsでは next/link という、クライアントサイドルーティングを実現するためのコンポーネントが提供されており、これを用いることでブラウザのページ読み込みなしに、素早いページ遷移を実現できます。

しかし、ブラウザのページ読み込みなしにページ遷移を実現できる関係上、nginxのconfでルーティングを設定していなくても、pagesディレクトリ以下にページが配置されていればそちらにルーティングされます。そのため、その挙動を認知していないエンジニアがリリースする際に問題が生じることが何度かありました。

この問題に対しては、リリースガイドラインに注意文言を追記することで対応しました。

振り返り

全体的な振り返り

再掲となりますが、フロントエンドリプレースの目的は以下の二つでした。

  1. 香水診断のような、香り選びをサポートするリッチな体験をスピーディに提供したい
  2. スタートアップにとってエンジニア採用はボトルネックになるので、候補者から見て開発者体験の良い技術スタックにしたい

1のリッチな体験のスピーディな提供については、Reactのロジックとコンポーネントの再利用化、Tailwind CSSによるデザインシステム定義、TypeScriptの型安全の恩恵によって、比較的スピーディに行えるようになりました。

2の採用面については、当時、弊社はエンジニア3名程度のシリーズAのフェーズで、開発チームの拡充が急務な状況でしたが、Next.jsを使っているということで採用の障壁は軽減しました。React+TypeScriptの経験者の方々を副業として複数名迎え入れることができ、フロントエンドリプレースの進行だけでなく新機能の開発を加速することができました。Railsの上にフロントエンドのコードを載せていた場合、モダンフロントエンドに特化しているタイプの人の採用難度が高くなっていたと思います。

技術選定の振り返り

フロントエンド技術の選定についてはどれも満足しています。

Next.jsは、少ない設定で開発をはじめられた恩恵をリプレース初期に存分に受けることができましたし、routerやSSRを他のライブラリに頼ることなく、Next.jsが提供する枠組みの上で書けばいいので迷いがなくスムーズでした。

TypeScriptについては、初期は後述のコンパイラオプションの設定により型安全性の恩恵を完全には享受できていなかったものの、それでも動作確認・テストの前に型エラーで実装の誤りの検知できる点や、コードリーディングのしやすさなど、さまざまな恩恵を得られました。

Tailwind CSSについても、Tailwind CSSが提供しているデフォルトのデザインシステムをベースにデザインシステムを作れるという点が、デザインシステムを1から作ったことのあるエンジニアがいない私たちにマッチしており、助かりました。リプレース初期から、デザイナーと連携してFigma中のコンポーネントにデザイントークンを指定してもらうようにすることで、テキストスタイルやカラーに関するコミュニケーションが円滑に進みました。

対して、バックエンドのライブラリ選定については、反省点がありました。

JSONリアライザーであるActiveModelSerializersは、メンテナンス面で不安がありますし、2021年にOSSとして公開されていた albaの存在をもっと早い段階で気づいていければ意思決定が変わっていたと思います。albaの存在を検知したのが2022年の9月ととリニューアルがほぼ完了しているタイミングだったので、単純にRuby gemの情報キャッチアップが甘かったです。

認証gemについては、開発リソースの状況的にdeviseを剥がしてauth0などに移行するのは現実的でなかったにせよ、DTAを使わずにDeviseだけで済ませるという方法をもう少し入念に検討しても良かったと思っています。deviseに加えてDTAに依存してしまっている関係上、認証を他に移行する際に考慮することが増えるため、依存ライブラリを少なく抑えるというのは当初考えていたよりも重視すべきでした。

TypeScriptのコンパイラオプション設定の振り返り

TypeScriptのコンパイラオプションをstrict: trueにせずに開発を進行したのは、過ちでした。リニューアル開始時期の開発メンバーが全員TypeScript初心者であったこともあり、まずはゆるくはじめようということで全てデフォルトの状態ではじめていましたが、以下のようなデメリットがありました。

  • strictNullChecks があれば防げたバグが何度か起こった
  • strict: true にしていれば、TypeScriptの習熟曲線の角度をあげられた

型安全性を放棄していたので、習熟度の低い初期フェーズでの開発が素早く済んだのは事実ですが、いま思うと最初の数週間をstrict: trueによるコンパイルエラーで怒られておけばTypeScriptの型システムへの習熟がより早期に行え、早いタイミングで学習コストを回収できたと考えています。

複数回のバグ発生を起因に、少しずつコンパイラオプションをtrueにしており、現時点では以下のオプションはtrueになっています。

今後、このように、初期の設定をゆるく、後に厳しくしていくという場合初期にきつくすることによる技術の習熟機会を失うことを考慮して意思決定していく必要があります。

"noImplicitAny": true,
"alwaysStrict": true,
"noImplicitThis": true,
"strictFunctionTypes": true,
"strictNullChecks": true,

リプレースのプロセスの振り返り

いまとなってはそれ以外の選択肢はない気がしますが、1ページずつリニューアルする選択をしたのは間違いなく正解でした。

リニューアル優先度の低いページを後回しにして、新しい新機能の追加でユーザー体験を改善する、といった柔軟なリリースフローを組むことができました。

また、定期便の登録導線をリリースした際、CVRが下がったことがあり、その導線だけをロールバックして改善をする、というアクションをとれたのもこの段階的な方法でとっていたためです。

リプレース期間中に発生した問題の対応についての振り返り

細かい話にはなりますが、<Link>の使用による意図しない新ページの露出の問題については、リリースガイドラインで人間の行動に頼ったやり方でなく、実装レベルで問題が起きないようにするやり方もありました。実際、新しくジョインしたメンバーがリリースガイドラインを把握できておらず、意図しないページの露出の問題が再度起きるということがありました。

以下のように、nginx.confとともにフロントエンドでも移行済みのパス集合を持つようにし、<Link>のラッパーコンポーネントを使用することで解決できそうです。

また、nginx.confを更新する際に、フロントエンドの移行済みパス集合を更新し忘れることを防ぐために、Github Actionsで変更ファイルを検知してエラーを出すなどすれば、より確実に問題の再発を防げそうです。

こういった、人の頑張りに依存せずに問題を防ぐような仕組みをさくっと作ることも、今後はチーム全体でこころがけていきたいです。

const ALREADY_REPLACED_PATHS = new Set(['/top', '/items']); 

export const PathAwareLink = (props: React.ComponentProps<typeof Link>) => {
  if (alreadyReplacePaths.has(props.href as string)) {
    return <a {...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)} />;
  }
  return <Link {...props} />;
};

最後に

カラリアのフロントエンドリニューアルの背景、技術選定、リプレースのフロー、課題、振り返りについて紹介しました。フルスタックのMVCフレームワークからフロントエンドをリプレースする際に参考になれば幸いです。

ハイリンクでは、プロダクト開発をする上で、スピーディなユーザー体験の向上と、継続的な内部構造のリファクタリング・アップデートのどちらも重視しています。

技術を大事にしながら、ユーザーに価値を届けることにコミットしていきたいエンジニアを募集中です。

リニューアルについて、この記事に載せきれていない苦労話などもあったりするので、ぜひ一度お話しできれば幸いです。

herp.careers