【Next.js】AppRouter beta時代の技術構成・選定・トラブルシューティングログ
はじめに
- 株式会社Awarefyというメンタルヘルス領域のスタートアップでフロントエンドを中心に担当している @nitaking です
- 2023年4月頃に構築したWEBサイトの構築した際の構成の経緯、技術選定、トラブルシューティングを備忘録として記録しておきます。
- すぐ陳腐化する技術の世界なので(もう半年経って状況変わっているし)スナップショットとして活用ください。
- また社内ドキュメントにもしてしまおうの趣旨なので、その点をご留意ください
時期
- 2023年4月頃、Next.js AppRouter(appDir)がbetaだった時期
背景
- メインプロダクトはモバイルアプリ(Flutter)
- バックエンド通信はgRPC
- 全社的にクリーンアーキテクチャを導入
基本構成
- React, TypeScript, Next.js
- React
- React・Next.jsの方向性にやや懐疑的な点もあったのでSvelteなどの他フレームワークも検討したが、慣れと採用観点で採用
- Next
- Next.jsに浸かりすぎるとベンダーロックインされるリスクに怯えていたし、Nextを前提とするライブラリに依存度が高まる点も当初リスクとして捉えていた
- Hono, Vite + React + React Routerも検討したが、結局Nextのほうが構成もシンプルでファイル構造・Server Componentのメリット・Font・Imageのパフォーマンスの恩恵を受けられるのでは判断
- ベンダーロックインのリスクを甘んじて受け入れることで享受する恩恵のほうが大きいと判断
- Remixも有力だったが知識不足。Next.js v13以前のルーティング関連でのパフォーマンスの差分もv13で解消されたので好みで使い分けれる範囲と認識
- Next.js(Vercel)のほうが市場・技術を牽引しているのでそちらがベターと判断
- React
-
Mantine
-
当時v6はSSRには対応していないものの、appDir(AppRouter)にはギリ対応されていたため、CSR(use client)では利用可能 -
CSS Moduleやtailwindでの実装もServer Componentを踏まえると選択肢としてベターだったが、Delivery優先し、Mantineを活用した実装で進行
-
現在はv7がリリースされ、Emotionの依存を外し、Pure CSSでの実装に置き換えられた
-
今後v7へmigration予定
-
ディレクトリ構成
- 当初ドメインを入れない選択をし、Featured baseで構成した
- 以下の構成に近しいもの
- が、ファーストリリース後にドメインを導入することを決意
- gRPCのインタフェースを含め、Contextual Block(文脈付けられたブロック)のデータ構成であるため、ドメイン層がないと同一モデルに対し実装コストが繰り返し発生し、知識が分散する状況に
-
また、他リポジトリとの構成の乖離が強まることでメンバー間のスイッチングコストが高いと判断。同一設計で統一することによる開発スピードの向上を優先。
-
結果的に、複数のディレクトリ思想を適用
-
プレゼンテーション層にはFeatured basedを適用
-
ドメイン層にはDomain basedを適用
-
マイベストのディレクトリ設計思想と同様に、個人的に複数の思想を適用してもいい(したほうが整理が進むときもある)と思っていて、この最近ではこの考えを支持している
src ├── app ├── components │ ├── features │ ├── pages │ ├── svgs │ └── ui ├── config ├── core │ ├── domain │ ├── infrastructure │ └── use-case ├── features ├── hooks ├── lib ├── pb ├── stores ├── styles ├── types └── utils
-
ホスティング
- Vercel
- 当初はNext.js × Cloudflare Pagesを狙った
- Vercelにベンダーロックインされていいんだっけ思考と、Cloudflare Pagesのイケイケ感などを理由に採用を進行
- セットアップでもドキュメントが足りているような足りていないような・・・だったが初期セットアップは成功
- しかし、開発の進行とともに、worker / wranglerのlimitでエラーが発生するようになる
- プランを上げて上限解放しても解消されず、もはやCloudflare側の対応を待つしかない状態になったため、泣く泣くvercelへ
- あんなに時間と労力をかけたのにVercelではノンストレスでデプロイ完了
- エコシステムが整っていることのありがたみを痛感するとともに溶けた時間を供養した・・・
State管理
-
Zustand
-
当初は Recoil を採用したが、リライト時に移行を決意
-
React / hooks に依存している点、recoil-persist が貧弱で recoil 周辺のエコシステムに不安を覚えた
- hooksへの依存度
- ドメインを入れてクリーンアーキテクチャを行う際、リクエストの共通部分でグローバルストアの情報にアクセスしたかった
- hooks経由必須になると、バケツリレー or infrastructure からhooksへの参照があるので激しい抵抗感が発生
-
recoil-persist
- zustand は zustand-persist が indexed db 対応しているなどを比較
persisting-store-data.md
- zustand は zustand-persist が indexed db 対応しているなどを比較
- hooksへの依存度
-
Zustand は jotai の作成者でもある Daishi さんのライブラリであり、エコシステムがしっかりしていて安定していそうなのがGood
-
-
immer
- Stateのクラスのインスタンスが登場してきたため、レンダリング調整が面倒になりそうでした( Reactのshallow equalの話は azuさんの記事を参照 )
- イミュータブルにしてくれる immer を導入
- zustandでもドキュメントが用意されており無事適用できました。Zustand最高
- 参考用に codesandbox を残していたのでよろしければどうぞ
抽象化
- ファーストリリース以前は Zod を介して infrastructure 部分である proto の型を生成していた
- しかし、Zodに密結合してしまい、抽象への依存ができていない状態へ
- Zodのようなスキーマライブラリは陳腐化も激しい
- ドメインを適用する際にクリーンアーキテクチャとしても具体と抽象を分離するためにZodをモデルから引き剥がしてDI化
-
tsyringe を導入
- InversifyJS のときにあまり体験がよろしくなかったのでJSのDIに懐疑的だったのですが、tsyringeはノンストレスだったので認識を改めました。
gRPC / Connect
- クライアントは connect-es
- Connectとはなにかについては弊社CTO記事などを参照ください
-
Flutter(Dart)では通信できるのに、WEBブラウザからは通信できない事象に遭遇
-
WEBブラウザからの通信時にCORSでPreflightリクエストが行われ、Dartからの単純リクエストとは異なるリクエストになり、AWS ALBのヘルスチェックに失敗する
-
弊CTOが対応した際に得た知見と、最終的なAWSからのサポートの返答内容はこちら(そして僕はそれをただ見守っていました・・・🙃)
(1) クライアントアプリケーションは HTTP/2 でリクエストを行い、かつ Preflight request を行うため、ターゲットのプロトコルバージョンに gRPC を指定すると、ALB の動作の制約上、ALB が 464 レスポンスを返してしまう。
(2) ターゲットのプロトコルバージョンに gRPC 以外を指定すると、アプリケーションが GET リクエストに未対応のため、意図したパスでのヘルスチェックに失敗してしまう。
現時点における ALB のヘルスチェックの動作でございますが、ターゲットのプロトコルバージョンによって、次のような違いがございます。
- HTTP/1 および HTTP/2 の場合 : GET リクエスト
- gRPC の場合 : gRPC リクエスト
ターゲットのプロトコルバージョンに gRPC を指定した際にヘルスチェックが成功し、gRPC 以外を指定した際にヘルスチェックが失敗する事象は、上記の通り、ヘルスチェックリクエスト方法の違いによって発生したものと判断しております。
一方で、現時点における ALB の動作といたしまして、ヘルスチェックのリクエスト方法 (POST リクエストなど) をお客様にてご指定いただくための機能の提供はございません。
そのため、大変恐れ入りますが、お客様にて実施いただいておりますように、ヘルスチェックパスに "/" など、アプリケーションに存在しないパスをご指定の上、成功コードに 404 をご指定いただくことが、現時点における唯一の回避策となります。
-
-
Any の unpack / packに失敗する事象
-
Anyをplainでは扱うことができず、
createConnectTransport
時にuseBinaryFormat
を指定してbinaryで扱うことで変換可能となったimport { createConnectTransport } from '@bufbuild/connect-web'; createConnectTransport({ baseUrl: config.backendURL.href, useBinaryFormat: true, // <- this })
-
デメリットとしてはbinaryで扱うためdebugはしにくくなる(networkタブで見てもbinaryで解読不可)
-
メリットとしては通信量が減るのではないか、と思っているが調査不足
-
リクエストキャッシュ
- Tan Stack Query (React Query)推しだし、Connect Connect-Queryとして対応していたので使いたかった
- が、以下のIssueによりエラーが発生してしまう
- Providerに起因するエラー(?)だったため、Providerを利用しない swr を採用して回避
- もう隅から隅までvercelにおんぶにだっこ
おわりに
を受けて各社事例を書かねばならぬと筆をとりました!
記載内容に誤りあれば編集リクエストお待ちしております!そしてみなさんの事例もお待ちしております!
Discussion
jotaiの名が出ていたので。
質問ですがState管理ライブラリ候補に、jotaiは上がっていましたか?
仮に候補にあったとしても上記が理由で、jotaiではなくZustandが選ばれたという認識ですが。
ありがとうございます!
そうですね。Jotai は初回の Recoil 採用時に候補に挙がりましたが、(experimentalですが)metaのライブラリなので Recoil の採用に至りました。(あと、過去に採用してたので)
2度目の選定の際には、「チームが一番開発スピードを得られること」を重視したため、Flutter製のメインプロダクトが View Model を使った MVVM でState管理していたこともあり、redux/fluxのようにstateとactionを1つの塊で扱えるほうがチーム内でスイッチングコストを抑えてシームレスに開発できるはず、と考えたのが一番の理由ですね。
また、Recoilを使った際には stateValue と setState を別々にexportしていたのですが、ちょっと過剰気味にやりすぎてしまっていて、exportこんなしまくらなくてもいいのでは、だったり、もう少し別の管理の仕方にしたいな、と思っていたことも一要因ですね。