はじめに
こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_kimuson です。主にフロントエンドを中心に開発生産性の向上に取り組んでいます。
今回は、カイポケコネクトのフロントエンドを単一のNext.js構成からマイクロフロントエンド化した話を紹介します。
スパンで言うと提案をしてから9か月ほど経っているのですが、ようやく形になってきたので方針や試行錯誤した知見を共有できればと思います。
背景・元々のアーキテクチャ
まず、元々の構成を簡単に説明しておきます。
カイポケコネクトのフロントエンドは、GraphQL Federation*1 を行うBFF*2 Serverに対して巨大なフロントエンドが建っている構造です。
Next.jsを使っていますが、SSR*3 は行わず静的な構成です。Pages RouterでStatic Buildした成果物をS3でホスティングするだけのシンプルな形ですね。

組織構造としては、ストリームアラインドチーム*4 がNext.js内のページで区切られた小さいアプリケーションごとにオーナーシップを持っています。
例えば図のapp1のチームは主に src/pages/app1, src/services/app1 のオーナーシップを持つような形です。
分割のモチベーション
もともとはミニマムに単一のNext.jsで開始していたこのプロダクトですが、組織拡大・時間経過とともに肥大化を続けており、ペインが出てきている状況でした。
課題1: 各チームごとにバリューストリームを持ちたい
まずそれぞれのアプリケーションの開発はチームが分かれているので、当然それぞれのチームでバリューストリームを持ち、リリースをしていきたいのですがビルドが一括になる都合上個別でのデプロイを行うことができませんでした。
結果、2週間毎にまとめて足並みをそろえてリリースを行う「リリーストレイン」を長らく実施していましたが
- もっと高頻度にリリースをしていきたい
- アプリ単位でQAやリリースを行って行きたい
と言った需要が大きくなっていきました。
課題2: CI やローカル環境の肥大化
本来自チームとは関係ない・全チームのソースコードが含まれているパッケージになってしまっているので
- 変更に関係ない対象のCI(test, build, lint, 型チェック)を動かす必要がある
- ローカルで全部入りのNext.js Dev Serverを立てる必要がある
と言った状態でした。
今後もアプリの小グループやコードベースも増えていくので今の構成のまま進んで大丈夫か?という懸念が出つつありました。
マイクロフロントエンドで解決を目指す
これらの課題に対して、Verticalにフロントエンドを分割する構成に移行することで解決を目指しました。

アプリごとに独立したNext.jsアプリケーションを持てるようにします。
これにより
- ローカル環境で自分が触らないアプリは共有環境のリソースに接続できる
- CI/CDも分割され、アプリ単位で必要なCIに絞って回すことができるようになる
- アプリのデプロイ単位を分割できるため、チームごとに自分のアプリだけデプロイすることが可能になる
という形でペインが解消されていく構想で開始しました。
実現方法
分割の実施には複数のアプローチを検討しましたが、分割すること自体が非常に大きな取り組みでありスコープを極力絞ったミニマムな方針で分割を行いました。
例えばアプリごとにサブドメインを分ける等も考えられましたが
- アプリのパスは変えない(
/app1ならapp1のNext.jsが動くだけ、という構造) - パスを変えないので旧パス・新パスのリダイレクト等も不要
- これまで同様単一のs3バケットにデプロイする形を維持
とすることで、パッケージを分割する以外の関心をあまり気にせず進められるようにしました。
basePath を活用したインフラ構成がほぼ変わらない方法
Next.jsには basePath オプション が存在しており、サブパスにおいて配信する構成に対応しています。
これを使ってそれぞれのアプリを basePath ありでビルドし、成果物を結合してS3にアップロードします。
# 各アプリのビルド成果物
apps/
├── app1/out/ # basePath: /app1 でビルド
│ ├── _next/
│ └── index.html
├── app2/out/ # basePath: /app2 でビルド
│ ├── _next/
│ └── index.html
└── app3/out/ # basePath: /app3 でビルド
├── _next/
└── index.html
↓ cp -r で結合
# S3にアップロードする成果物
dist/
├── app1-path/
│ ├── _next/
│ └── index.html
├── app2-path/
│ ├── _next/
│ └── index.html
└── app3-path/
├── _next/
└── index.html
静的ホスティングでの Dynamic routes 対応
上記で基本的には期待通り動くのですが、Dynamic routesの解決に関しては追加の対応が必要です。
そもそも分割関係なく一般的にNext.jsの静的ホスティングではDynamic Routesが動作しないのでワークアラウンドを行う必要があることが知られています。
詳細な方法はインフラ等にも寄るのでまちまちですが、概ねこういう対応が必要です。
- インフラ側:
/posts/10のようなアセットが存在しないパスへのリクエストでも/404.html等を返すようにする - フロントエンド側:
/404等受けたFE側でwindow.location.pathname(/posts/10) を確認し、存在するルートあればrouter.replaceしてフォールバックする
アプリ分割すると当然アプリごとの /404 からしかソフトナビゲーションで正規のパスにフォールバックできませんから、個別の /404 に流す必要があります。
我々の場合は、CloudFront Functionsで下記のような対応を入れることでDynamic Routesに上手く対応しています。
- 静的アセットは正規表現でマッチさせてそのまま帰す
- それ以外はCloudFront Functionsが各アプリのパスを知っており、
/app1-path→/app1-path/404.htmlを返す、というような処理を生やす
これにより、分割して結合したビルド成果物をs3に配信する形でDynamic Routes含め正常に動かすことができるようになりました。
モノレポの管理
続いて、ローカルやCIでのパッケージ管理についてです。
モノレポ管理はpnpm workspace + Turborepoの構成を採用しています。
pnpm workspaceは他のパッケージマネージャーと比較してもワークスペース周りの機能が充実しています。
基本的にはワークスペースプロトコルを使用し、内部パッケージ間の依存を解決しています。ワークスペースプロトコルを使用すると依存元のnode_modules/pkg-name部分がパッケージの実態へのシンボリックリンクになるため、ホットリロード等がほぼ同一パッケージ内で依存していた場合と変わらないような体験で利用できます。
./
├── packages/
│ └── shared/ # 実際のパッケージの実体
│ ├── package.json
│ └── src/
│ └── index.ts
│
└── apps/
└── app1/
├── package.json # "shared": "workspace:*" と記述
└── node_modules/
└── shared/ # → ../../packages/shared へのシンボリックリンク
また、パッケージを跨ぐスクリプトの管理にTurborepoを採用しています。
Turborepoでは --affected というオプションが提供されており、例えば turbo run build --affected と実行するとgitのdiffから依存関係を含めて実行する必要があるパッケージを計算し、実行してくれます。
また、我々はまだそういう構成が必要になっていないので利用していませんが、「ビルドを実行するには依存するパッケージがビルドされている必要がある」というような依存関係も定義できるのでここういった実行すべきタスクの管理がお任せできて便利です。
internal package における TypeScript の型解決
internalでないパッケージではビルドを行い d.ts と .js を公開して利用させる構造が一般的です。
// 一般的な npm package の公開方法
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.mjs"
}
}
}
ただしinternal packageでは一々共通パッケージでビルドしていると開発体験が悪いので、.ts ファイルを直接公開し、実際にトランスパイルを行うのはアプリ側にしています。
// TypeScript ファイルを直接公開する
{
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
基本この形で開発者体験を維持して依存解決をしていますが、一部ビルドをしたいパッケージもワークスペース内に存在しているのでそちらについてはcustom conditionを使って内部でのみ .ts に解決させたりもしています。
この辺りは以前記事を書いているのでよければあわせてご参照ください。
直接 .ts を公開するために気をつけること
体験が圧倒的に良いので .ts の直接公開がおすすめですが、いくつか気をつけておくべきポイントがあります。
まずは、.ts を公開するということは複数のパッケージから公開されたtsファイルの型チェックが行われるということです。
そのため、極力パッケージ間の型チェックに関するtsconfigのオプションを統一しておくことが望ましいです。我々の場合は共通のtsconfigを packages/tsconfig として用意し、これをextendsして必要な箇所だけ上書きする構造にしています。
{
"extends": "@my-pkg/tsconfig/base.json",
"compilerOptions": {
// ... 上書きする設定
}
}
次に同様の理由でパッケージのバージョンを揃えておくことが望ましいです。
アプリごとのTypeScript本体のバージョンが異なっていたり、依存のバージョンが異なっていると一方では通る型チェックがもう一方では通らないと言ったことがありえます。
pnpmではCatalogsというワークスペース内で利用するバージョンを揃える機能が提供されているのでこれを使うと統一を簡単に実現できます。
# pnpm-workspace.yaml packages: - apps/** - packages/** catalog: 'react': 19.0.0 catalogMode: strict cleanupUnusedCatalogs: true
// package.json { "dependencies": { "react": "catalog:" } }
共通のアプリケーションコードをどうするか問題
app1とapp2で「共通で利用しているコード」は、パッケージに切り出す必要があります。いわゆるcommonやutilsという名前がつきがちなコード郡ですね。
まず理想形を言うとちゃんと責務ごとにパッケージを分けていくのが良いと思います。 一方、現実的にはリファクタコストが大きくて分割のコストが大きくなってしまうため、どかっとまとめて単一のsharedなパッケージとして切り出すことにしました。
これはパッケージ間の循環参照を許容しないためです。
例えば auth パッケージと test パッケージを用意して、testではauthにある型が必要、authではテストするのにtestパッケージのユーテリティが必要となると相互依存の関係になってしまうことになります。これを許容してしまうとビルド等の依存関係を正しく解決できますか?とか、型やパッケージ自体の依存解決は問題ないか?等と考えることが増えるため許容しないことにしています。
上記を基本方針としながら
- まずは単一のNext.jsアプリがワークスペースの中で動く構成
- 命名からcommonのようにわかりやすく共通部分となっている箇所を切り出し、1のアプリがそこに依存する状態を作る
- 小さい・変更の少ないアプリから実際に移行しながら必要な箇所を特定して共通パッケージに切り出す
という流れで共通コードを分離しながら分割を進めていきました。
結果
この記事を書いている2026年1月現在ですべてのアプリ分割は終わっていませんが、残すことあと1アプリとなりました。
現時点での結果をまとめようと思います。
開発環境で必要なアプリだけ起動できるようになった
ビルド単位が別れたことで触るアプリケーションの next dev のみ立てれば良い形になりました。他のアプリに関しては共有環境のbuildをそのまま受け取るだけで良いので開発マシンにも優しいですし、他アプリ起因のトラブルが起きづらく成りました。
CI は turborepo --affected で必要な依存だけ実行
冒頭で紹介した通りTurborepoには --affected オプションがあり、変更に影響があるスクリプトだけ実行することができます。
CIの構築も手軽で、CI用のワークフローを用意して --affected でテスト等を実行するだけで必要なパッケージに絞ったテスト等が実行されるようになります。
ライブラリの段階移行がしやすくなった
副次的に狙っていたことではありますが、ライブラリのアップデートをアプリ単位で進められるようになりました。
コードベースが大きいので、密に依存しているライブラリのMajorアップデートや別ライブラリへの移行等は大変になりがちです。例えばJestはESM Support周りが辛いのでVitestへ移行をしているのですが、こういう話をアプリごとで実施できるようになりました。
ちなみに、前述した通り我々はパッケージのバージョンはすべてpnpm catalogで一元管理していますが、named catalogsを使って複数バージョンの共存も可能になっています。
# pnpm-workspace.yaml # 通常のカタログ catalog: react: 17.0.2 react-dom: 17.0.2 # Named Catalog catalogs: react17: react: 17.0.2 react-dom: 17.0.2 react18: react: 18.2.0 react-dom: 18.2.0
これでバージョンを一元管理しつつも、特定のパッケージだけMajorバージョンアップさせると言った対応が可能になっています。
https://pnpm.io/catalogs#named-catalogs
残っている課題:shared 肥大化問題
分割後に課題もいくつか残っているのですが、特に重要なので「sharedの肥大化問題」です。
まず、アプリケーションの共通部分を切り出したsharedのパッケージの変更ではホットリロードが効かないという問題があります。
これはpeerDependenciesを持つパッケージを workspace:* プロトコルで解決する際に起こってしまう問題であり、事情が複雑なのでこれ自体で記事を書いています。
また、ホットリロードの問題を置いておいてもsharedがファットだと結局変更が他チームに影響することが増えます。調整ごとの時間が増えてしまうので、組織的にも良くないと思っています。
対策としてsharedを減らしていく方向で進めています。
そもそも移行を優先してどかっと持ってきてしまったので
- デザインシステムとして昇華されていないが、ドメインの事情を持たないUI Patternはデザインシステムに持っていけないか?
- アプリを跨ぐ共通のUIは本当に共通にしないとダメなものか?コピーの方が望ましくないか?
等を検討し、ちゃんと用途ごとのinternal packageに分離してsharedを縮小していきたいと思っています。
まとめ
巨大なNext.jsアプリケーションをマイクロフロントエンド化した話を紹介しました。
ページパスやインフラ構成への影響を最小限に抑えたことで、現実的に分割を進められました。
おかげでCIや開発環境、依存ライブラリの段階的アップデートなどアプリケーション単位で実施できることが増えました。
まだ課題が残っているのは書いたとおりなので、引き続き改善を続けていきたいと思っています。
*1:別途のグラフを持つ複数のGraphQL Serverを束ねるGateway Serverを用意し、クライアントから統合された1つのグラフに対してリクエストを行うことができるアプローチ。
*2:Backend For Frontend の略。フロントエンドとバックエンドの中間に配置されるサーバーで、フロントエンドから見て複雑なバックエンド呼び出しを隠蔽する等の責務を持ちます。
*3:Server-Side Rendering の略。意味が揺れがちですが、ここでは「リクエストごとにサーバー側でHTMLを組み立てて返す構成」の意味で使用しています。
*4:書籍チームトポロジーにて紹介されているチーム分類の1つです。基盤を提供したり高度な専門領域を扱うチームと対比してビジネスドメインに沿ってプロダクトの開発を進めるチームを指します。