技術 約9分で読めます

pnpm vs npm vs yarn 2026をNext.jsモノレポの実測で比べる

いけさん目次

Next.js 16 + Shadcn/ui + RailwayのモノレポでCIのcold install(キャッシュなし・node_modulesなし)を測ると、npm 10.9が87秒、Yarn Berry 4.5が72秒、pnpm 9.15が41秒だった。
node_modulesのディスク使用量もnpm 1.4GB、Yarn PnP 890MB、pnpm 610MB。
DEV Communityの実測記事の数字だ。

ただ、速度差より先に刺さるのは依存解決の話だ。
著者が詰まったのは、pnpmの厳密な依存解決で@radix-ui/react-dialogまわりの未宣言依存が露出したところだった。
npmならflat hoistingでたまたま見えていた依存が、pnpmでは見えなくなる。

自分も以前、React2Shell対応中にnpm installが壊れてpnpmへ逃がしたことがある。
そのときは「npmが落ちるからpnpmで通した」という雑な移行だった。
今回の記事はその次の話で、pnpmへ移ったあとに、どこまでnpm互換の逃げ道を開けるかを扱っている。

速さの差はnode_modulesの作り方に出る

pnpm公式の説明では、npmで同じ依存を100プロジェクトが使うと100コピーが保存されるが、pnpmはcontent-addressable storeに保存し、各プロジェクトからhard linkする。
さらに、npmやYarn Classicでは依存がrootのnode_modulesへhoistされるため、package.jsonに書いていない依存にもソースコードから届いてしまう。
pnpmはデフォルトで直接依存だけをrootに置く。

この違いは、速度より先に「壊れ方」に出る。
このNext.js 16モノレポでは、npmだと見逃されていた@radix-ui/react-compose-refsの未宣言依存が、pnpm移行後のbuildでCannot find moduleとして表に出た。
これはpnpmのバグというより、依存パッケージ側がnpmのflat hoistingに甘えていた状態だ。

ただ、ビルドが落ちる側から見ると理屈はどうでもいい。
本番直前にUIライブラリが壊れたら、原因が上流の未宣言依存でも作業は止まる。

shamefully-hoistは移行作業を終わらせるがpnpmを薄める

元記事で出ていた回避は、.npmrcで対象scopeだけをpublic hoistする方法だった。

public-hoist-pattern[]=@radix-ui/*
public-hoist-pattern[]=@floating-ui/*

この設定は、壊れているscopeだけをnpm風に見える場所へ出す。
全体をshamefully-hoist=trueにするより範囲が狭い。
pnpm公式にもnodeLinker=hoistedのような逃げ道はあるが、そこまで広げると「未宣言依存を落とす」というpnpm側のうまみが消える。

移行時に見たいのは、pnpmが速いかどうかよりも、どのパッケージでhoistingを戻したかだと思う。
.npmrcpublic-hoist-patternが増えていくなら、そのscopeは互換性の借金として残る。
反対に数個で止まるなら、pnpmの厳密さを保ったまま運用できる。

CIのinstall速度はstoreキャッシュで決まる

87秒 vs 41秒はcold install、つまりいちばん遅い条件での比較だ。
日常のCIではlockfileが変わらないビルドも多い。
前回ビルドのpnpm storeやnpmキャッシュが残っていれば、レジストリへのフルアクセスが減って差は縮む。

pnpmの速さはstoreの再利用に支えられている。
pnpm storeはcontent-addressable storage(ファイルの中身をハッシュで管理する共有ストレージ)で、ダウンロード済みのパッケージをhard linkで使い回す。
DockerfileやCI設定でstoreの場所が毎回消えるなら、cold installを繰り返すだけになる。
元記事ではRailway向けにstore-dirを明示していた。

ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

RUN pnpm config set store-dir /root/.pnpm-store

この観点はVercelでも似ている。
最近このブログでAstro 6のVercelビルドエラーを追ったときも、ログにUsing pnpm@10.x based on project creation dateやbuild script制限が出ていて、最初はpnpm 10を疑った。
その件の本体はAstroのscript変換経路だったが、CIログにpnpmやキャッシュの差分が混じると、かなりもっともらしいノイズになる。

Yarn Berryは速さだけで選びにくい

Yarn BerryのPnPはnode_modulesをなくせるので、理屈としては強い。
ディスク使用量はnpmよりかなり小さい。
でもNext.js、Shadcn/ui、Radix UI、TypeScript、ESLint、Prismaのような普通のフロントエンド寄りstackでは、PnP対応の摩擦がpnpmより重く見える。

すでにPnP前提で巨大モノレポが回っているなら、移行しない判断は普通にある。
新規のNext.jsモノレポで「npmより速く、でもNode.jsのnode_modules前提から大きく外れたくない」なら、pnpmのほうが落とし所を作りやすい。

npmを捨てる理由がない小さいリポジトリもある

npm 10はもう「遅くて話にならない」だけの存在ではない。
著者もnpm 10の改善を認めたうえで、ディスク使用量とキャッシュなしCIでのinstall速度の差が大きいからpnpmを選んでいる。
小さい単体アプリ、オンボーディング重視の教材、npm前提の古いツールが残るリポジトリなら、npmのままでも十分なことはある。

pnpmへ移すなら、判断材料はこのあたりになる。

見る場所pnpm移行で効く読み方
CIのinstall時間cold installだけでなく、storeキャッシュありのビルドも見る
node_modulesサイズ複数アプリ、複数packagesで重複が大きいほど差が出る
build時のCannot find module未宣言依存か、hoisting前提のパッケージを疑う
.npmrcpublic-hoist-patternが少数scopeで止まるかを見る
workspace依存内部packageはworkspace:*でregistry解決を避ける

実際の移行はもっと泥くさい。
pnpm installが速く終わっても、next build、型チェック、lint、Storybook、E2E、デプロイ先のinstallログまで通さないと勝ちではない。

hoistingの差はサプライチェーン攻撃の表面積でもある

npmのflat hoistingにはセキュリティ面のリスクもある。
node_modulesにフラットに並んだパッケージは、どれでもrequire()で互いにアクセスできる。
攻撃者から見ると、npmのnode_modulesは入り口が1つあれば中を全部見渡せる構造だ。

たとえばタイポスクワッティング(名前を似せた偽パッケージを公開する手口)で悪意あるパッケージが1つ入ったとする。
npmのflat構造では、そのパッケージから同じnode_modules内の認証ライブラリ、ORM、環境変数を読むユーティリティなど、すべてにrequire()で手が届く。
package.jsonで宣言していなくても、ファイルシステム上は同じ階層にいるからだ。

pnpmのsymlink構造では、パッケージがrequire()できるのは自分のpackage.jsonに書いた依存だけになる。
悪意あるパッケージが入っても、到達できる範囲が宣言済みの依存に閉じる。
前のセクションで書いたhoistingの厳密さは、ビルドの正確さだけでなく、こっち側にも効いている。

.npmrcpublic-hoist-patternを広げるほど、この制限は緩む。
@radix-ui/*のようなUIライブラリ単体なら影響は小さいが、shamefully-hoist=trueまで行くとnpmと同じ可視性に戻る。
移行時に「とりあえず全部hoistして動かす」をやりがちだが、セキュリティの観点からもscopeを絞ったhoistで止めたほうがいい。

Bunのinstallは速いがnode_modulesはnpmと同じ構造

npm、Yarn、pnpmと並べるならBunも気になるところだ。
bun installはZigで書かれたネイティブバイナリで、cold installの速さはpnpmの半分以下になることもある。
グローバルキャッシュとhard linkを使う設計で、概念としてはpnpmのcontent-addressable storeに近い。

ただし依存解決はnpmのflat hoistingと同じだ。
package.jsonに書いていない依存にもrequire()で手が届く。
Radix UIの例でいえば、@radix-ui/react-compose-refsを宣言していないパッケージがBunのinstallでも普通に動いてしまう。
pnpmで壊れて初めて気づくような未宣言依存は、Bunでも気づけない。

前のセクションで書いたサプライチェーン攻撃の表面積も、Bunのnode_modulesではnpmと同じ広さになる。
pnpmのようなpublic-hoist-patternによる細かい制御もない。
すべてが最初からフラットに見えている。

このブログはAstro 6 + pnpm 10で動かしている。
AstroはBunをランタイムとしてもパッケージマネージャとしても公式にサポートしている。
bun installで入れてbun run devで起動する構成は普通に動く。
でもpnpmからBunに切り替えると、依存の厳密さがnpmレベルに戻る。
CIのinstall時間を削りたいだけなら有力な選択肢だが、hoistingの制御を維持したいならpnpmに残る理由がある。

Bun 1.2でbun.lockb(バイナリ形式)に加えてbun.lock(テキスト形式)が使えるようになった。
ワークスペースもサポートしている。
ただモノレポのworkspace連携は、pnpmのworkspace:*プロトコルほど成熟していない。

Bunにはもうひとつ、hoistingとは別の角度でサプライチェーン攻撃を減らす仕組みがある。
bun installはデフォルトでlifecycleスクリプト(postinstallなど)を実行しない。
npmではpostinstallがインストール直後に無条件で走る。
サプライチェーン攻撃の定番は、このpostinstallにマルウェアの展開コードを仕込む手口だ。

Bunでlifecycleスクリプトが必要なパッケージはpackage.jsontrustedDependenciesに書く。

{
  "trustedDependencies": ["@prisma/client", "esbuild"]
}

ここに入っていないパッケージのpostinstallは無視される。
pnpmにもonlyBuiltDependenciesという同等のオプションがある。
npmは--ignore-scriptsで全部止められるが、正規のネイティブビルドまで止まるのでプロジェクト全体には使いにくい。

pnpmはnode_modulesの構造で「到達できる依存の範囲」を絞る。
BunはtrustedDependenciesで「インストール時にコードが走る条件」を絞る。
攻撃を減らすアプローチが違うので、片方がもう片方の代わりにはならない。

Next.js自体が支配的に重い

元記事はNext.js 16のモノレポで測っている。
node_modules 1.4GB(npm計測)の中身で支配的に大きいのはNext.js本体とそのdependency treeだ。
87秒 vs 41秒のinstall速度差をパッケージマネージャの話として語っているが、ダウンロード対象の大半はフレームワーク側の依存である。

パッケージマネージャを変えればinstallは速くなる。
でも「何をinstallしているか」のほうが、install時間とnode_modulesサイズの支配的変数だ。
Astroの全依存はnode_modulesで数百MB程度に収まる。
同じモノレポ構成でも、フレームワークがNext.jsかAstroかで出発点の重さが全然違う。

自分はこのブログをAstro 6 + pnpm 10で動かしていて、管理系やSSR的に必要な一部だけNextやNuxtを使う構成にしている。
「pnpm vs npm vs yarn、どれにする?」の議論は実測として意味があるが、そもそもNext.jsをモノレポ全体のメインフレームワークにするかどうかのほうが、install速度にもdisk使用量にも根本的に効く。

セキュリティ面でも、Next.jsは直近でミドルウェア認証バイパスやSSRF系のCVEが出ている。
依存ツリーが大きいフレームワークは、hoistingの問題とは別のレイヤーで攻撃表面が広い。
パッケージマネージャの厳密さで未宣言依存を落とすのは有効だが、「そもそも何をdependenciesに入れるか」の判断はその手前にある。

参考