
Next.js
イベント
該当するコンテンツが見つかりませんでした
マガジン

技術ブログ
はじめに こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_kimuson です。 私たちのチームでは、開発体験の向上や今後の拡張に備えて大規模なフロントエンドアプリケーションのマイクロフロントエンド化を進めています。 アプリ分割については下記の記事で紹介していますので、よろしければ合わせてご参照ください。 アプリ分割の一環としてpnpm workspaceを使ったモノレポ構成を採用しているのですが、internal packageにおけるpeerDependenciesの扱いが課題になりました。 この記事では、pnpm workspaceにおけるpeerDependenciesの問題とその解決策について整理してみます。同じような構成で開発している方の参考になれば幸いです。 pnpm workspace と peerDependencies、何が問題なのか まずは問題の背景から説明します。 pnpm workspaceでは workspace:* プロトコルを使うことで、ローカルパッケージ間の依存をシンボリックリンクで解決してくれます。これがとても便利で、パッケージ内のファイルを編集すると即座に反映されますし、watchプロセスも不要なので開発環境が重くなりません。 典型的な構成はこんな感じです: // apps/main/package.json { " dependencies ": { " react ": " ^18.0.0 ", " shared-ui ": " workspace:* " } } // packages/shared-ui/package.json { " peerDependencies ": { " react ": " ^18.0.0 " } } apps/main が shared-ui に依存していて、 shared-ui はReactをpeerDependenciesとして宣言しています。ごく普通の構成ですね。 シンボリックリンクが引き起こす問題 UI Library等ではよくある普通の構造だと思いますが、workspaceでのinternalパッケージでは致命的な問題があります。 シンボリックリンクでパッケージを参照すると、Node.jsのモジュール解決の仕組み上、それぞれのパッケージが 異なるライブラリの実態を参照してしまう 、ということです。 シンボリックリンクにより shared-ui を参照していますが、それぞれが異なるreactインスタンスを参照してしまいます。 Node.jsはモジュールを解決するとき、呼び出し元のファイルから見て近いディレクトリから順番に node_modules を探していきます(参考: Node.js Documentation - Loading from node_modules folders )。 shared-ui のソースコードから import React from 'react' すると、まず packages/shared-ui/node_modules/react を見つけてしまうわけです。 peerDependenciesは本来「利用側と同じインスタンスを使ってね」という意図で宣言するものですが、シンボリックリンクだと期待通りの解決になりません。 実際に問題が起きるケース とはいえ、すべてのpeerDependenciesで問題が起きるわけではありません。問題が顕在化するのは、基本的にパッケージが グローバルな状態を持つ 場合です。 たとえば、わかりやすい例として単純なカウンターパッケージを考えてみます: // counter.ts let count = 0 ; export const addCount = () => { count++; } ; export const getCount = () => count; このパッケージが shared-ui にpeerDependenciesとして追加されていると仮定して、 shared-ui から addCount() を呼んでも、 apps/main から getCount() を呼ぶと0が返ってきます。別のインスタンスの別の変数を見ているからですね。 Reactも同様で、 useContext や useMemo などグローバルな状態に依存する機能を使うとエラーが発生します。一方、date-fnsやes-toolkitのような純粋な関数だけを提供するパッケージは、同じ実装が2箇所に存在するだけなので動作には問題ありません。 解決策 この問題に対するアプローチは大きく2つあります。いずれも peerDependencies を同じモジュールの実体に解決させる という方向性は同じです。 解決策1: バンドラーやテストフレームワークで依存解決をオーバーライドする 1つ目は、依存解決が行われる各ツールで、モジュールの解決先を上書きする方法です。 Jestの場合はこんな感じで設定します // jest.config.ts import { createRequire } from 'node:module' ; const require = createRequire(import.meta. url ); const reactPath = require.resolve( 'react' ); const jestConfig = { moduleNameMapper : { '^react$' : reactPath, } , } ; export default jestConfig; webpackを使うNext.jsやStorybookでも、同様に resolve.alias で上書きできます。Viteでも dedupe オプションが提供されています。 メリット: シンボリックリンクの利点(即座に反映、watch不要、軽量)を維持できる 必要な箇所だけパッチを当てられる デメリット: Next.js、Storybook、Jest/Vitestなど、依存解決を行うすべてのツールで設定が必要 新しいツールを追加するたびに設定を追加しないといけない 潜在的な問題は残る Ex. 実はグローバルな状態を持っていてロジックが壊れていることに後から気づく Ex. 同じコードが重複してバンドルに含まれてしまう 解決策2: pnpm の dependenciesMeta.*.injected を使う 2つ目は、pnpmが提供する injected オプションを使う方法です。 解決策1がパッチを充てるような対策だったことに対して、こちらは根本解決になります。 // apps/main/package.json { " dependencies ": { " react ": " ^18.0.0 ", " shared-ui ": " workspace:* " } , " dependenciesMeta ": { " shared-ui ": { " injected ": true } } } これを設定すると、pnpmは .pnpm ディレクトリ内に node_modules を持たないパッケージのクローン を作成します。 ディレクトリ構造は下記のようになります。 ./ ├── apps │ └── main │ └── node_modules │ ├── .pnpm │ │ └── <shared-ui-clone> │ │ ├── src (shared-ui のコピー) │ │ └── node_modules (空) │ └── @my-pkg │ └── shared-ui --> .pnpm/<shared-ui-clone> ├── packages │ └── shared-ui └── package.json apps/main からはこのクローンを参照するようになるので、 shared-ui のコードからReactをimportしても、クローン側の node_modules は空であるため親ディレクトリをたどり、 apps/main/node_modules/react へ解決されます。 つまり、 apps/main のコードと shared-ui のコードが同じreactインスタンスを参照するようになります。 見ての通り根本的な解決であり、理想的に見えますが開発体験が致命的に悪いという問題を含んでいます。 injected による開発体験の劣化と融和策 injected を使うと、 packages/shared-ui/src とそのクローンである apps/main/node_modules/.pnpm/<shared-ui-clone>/src は基本的にファイルごとにハードリンクで同期されます。 したがってファイルが追加されたり、削除されたりする場合には同期されません。 また、特定条件下でハードリンクではなく単なるコピーが行われるというUndocumentedな挙動があり、我々のプロジェクトではこの条件( shared-workspace-lockfile=false , postinstall あり)を満たすためコピーに寄って作成され、変更すら同期されないという状態でした。 起票したIssue: https://github.com/pnpm/pnpm/issues/9828 この問題を補完するために、 pnpm-sync-dependencies-meta-injected というツールがあります。これを使うと、開発中にソースコードの変更をwatchしてハードリンクを更新してくれるので、injectedを使いながらも快適な開発体験を維持できます。 ちなみにpnpm v10では syncInjectedDepsAfterScripts という公式オプションも追加されています。任意のスクリプト実行後にハードリンクを再同期してくれるもので、将来的にはこちらを使う選択肢もありそうです。 ただこれで全部解決だよね!ということでもなく「node_modulesを削除してpnpm iしなおさないと更新されない状態に定期的に陥る」といった問題が実際に発生しており、開発体験としては非常に悪い状態が残っています。 結局どちらを採用すればよいのか 少なくとも現在(2025年12月)ではどちらかが完璧な解決策にはなっておらず、トレードオフを考慮して選ぶ必要があります。 観点 解決策1(オーバーライド) 解決策2(injected) 開発体験 ◎ △ 設定の煩雑さ・手間 × ◎ 根本解決 × ◎ バンドルサイズ △(重複の可能性) ◎(重複なし) 可能であれば根本解決であるinjectedに寄せていきたい気持ちはありつつ、開発体験が悪すぎるので現状はオーバーライドに寄せています。 overrides 方式では解決できない問題もある 基本開発体験を優先してoverridesに寄せていますが、一部の共通パッケージではoverridesで解決できない問題があり、injectedも利用しています。 具体的には型解決の問題です。 型解決についてはpeerDependenciesの実態が異なるものに解決されていたとしてもバージョンさえ揃っていれば構造的部分型で型チェックされるため基本的には問題は起きません。 import { User } from 'peer-dep-pkg' import { getUser } from '@my-pkg/dep' const user: User /* node_modules/peer-dep-pkg */ = getUser() /* packages/dep/node_modules/peer-dep-pkg */ のように実態が異なっていても構造は同一であるため、型エラーにはなりえません。 弊プロダクトの場合はpnpm catalogを使って依存を管理しているため、バージョンは固定する運用をしているのでバージョン違いも起きません。 https://pnpm.io/ja/catalogs 一方、TypeScriptでも「classを使っておりprivateプロパティを持つ場合」では例外的に総称型で型チェックされるケースが存在し、そういった型が使われているライブラリがpeerDependenciesに存在すると型チェックは適切に行えません。 // ライブライ側のコード例 class ApiClient { private cache : unknown ; // ... } import { ApiClient } from '@my-pkg/peerDep' import { createApiClient } from '@my-pkg/dep' const apiClient: ApiClient /* node_modules/peer-dep-pkg */ = createApiClient() /* packages/dep/node_modules/peer-dep-pkg */ ; // => 実態が異なるため総称型でチェックされ型エラー この問題は解決のワークアラウンド的に回避も難しいので、一部のパッケージでは開発体験の劣化を許容してinjectedを採用しています。 終わりに pnpm workspaceにおけるpeerDependenciesの問題と解決策について整理しました。 シンボリックリンクによるモジュール解決の仕組み上、peerDependenciesが異なるインスタンスを参照してしまう問題がある グローバルな状態を持つパッケージ(Reactなど)や総称型になる型を公開するパッケージで問題が顕在化する 解決策としては「各ツールでオーバーライド」か「injectedオプション」の2つ 正直、overridesも設定が煩雑すぎるしワークアラウンドであること、injectedも体験が悪くてしっくりは来ていないのですが背景理解と方針検討にも苦労したので、同じようなworkspace化等に取り組んでいる方の参考になれば幸いです! また、pnpmのリポジトリでのこの問題は議論されているので、injected(根本解決)ベースでより開発体験も維持できるソリューションが出てくると良いなと思っています。 https://github.com/orgs/pnpm/discussions/3938
はじめに こんにちは!カイポケコネクトの開発推進チームでエンジニアをしている @_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つです。基盤を提供したり高度な専門領域を扱うチームと対比してビジネスドメインに沿ってプロダクトの開発を進めるチームを指します。
はじめに さくらのナレッジ編集部の法林です。 さくらインターネットはさまざまなITコミュニティの活動を支援しています。その一環として、東洋大学赤羽台祭という大学祭にサーバを提供しました。そこで本記事では、2025年11月 […]
動画
該当するコンテンツが見つかりませんでした











