monorepo + npm workspaces で TypeScript パッケージを共有する
モノレポ構成で TypeScript の型・関数・コンポーネントを複数アプリ間で共有する実装方法を解説。npm workspaces の設定・パッケージ間参照・ビルド順序・デプロイ時の依存解決まで実運用ベースで整理します。
はじめに
複数の Web サービスを並行で開発・運用していると、認証ロジック・UI コンポーネント・型定義などを複数リポジトリに重複コピーする状態になりがちです。npm workspaces を使ったモノレポなら、1 つのリポジトリ・1 つの依存ツリーで複数パッケージを共有できます。本記事では nagiyu-platform で採用している構成をベースに整理します。
ディレクトリ構成
nagiyu-platform/
├── package.json # ルート(workspaces 宣言)
├── package-lock.json
├── libs/ # 共通ライブラリ
│ ├── common/ # 型・ユーティリティ
│ ├── ui/ # MUI ベースの React 共通コンポーネント
│ ├── browser/ # ブラウザ専用ヘルパ
│ └── nextjs/ # Next.js 拡張
└── services/ # 各サービスアプリ
├── portal/web/
├── tools/web/
└── stock-tracker/web/
ライブラリは libs/、アプリは services/ 配下に集約。アプリ側からは @nagiyu/common のようなスコープ付きパッケージ名で参照します。
ルート package.json
{
"name": "nagiyu-platform",
"private": true,
"workspaces": ["libs/*", "services/*/web", "services/*/api"],
"scripts": {
"build:libs": "npm run build --workspaces --if-present",
"test": "npm run test --workspaces --if-present"
}
}
workspaces には glob を書けます。services/*/web のように深いパスも展開されます。private: true で誤って公開発行されるのを防ぎます。
ライブラリ側の設定
// libs/common/package.json
{
"name": "@nagiyu/common",
"version": "1.0.0",
"type": "module",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
},
"./push": {
"types": "./dist/src/push/index.d.ts",
"import": "./dist/src/push/index.js"
}
},
"scripts": {
"build": "tsc"
}
}
exports を細かく切ると、利用側で import { foo } from '@nagiyu/common/push' のようにサブパス指定ができます。Tree-shaking もしやすくなります。
アプリ側の参照
// services/portal/web/package.json
{
"name": "@nagiyu/portal-web",
"dependencies": {
"@nagiyu/common": "*",
"@nagiyu/ui": "*",
"next": "^16.0.0"
}
}
ワークスペース内のパッケージはバージョンを * にしておけば、npm install 時に シンボリックリンクとして node_modules/@nagiyu/common が作られます。ライブラリのソースを変更すると即座にアプリに反映されます。
TypeScript の型解決
tsconfig.json の paths で個別マッピングしなくても、@nagiyu/common は node_modules 経由で解決されます。ただし 型定義ファイル(.d.ts)が dist/ に出力されている必要があるので、ライブラリは先にビルドしておきます。
# 初回はライブラリをビルドしてから
npm install
npm run build --workspace=@nagiyu/common
npm run build --workspace=@nagiyu/ui
npm run dev --workspace=@nagiyu/portal-web
ソース直参照と dist 参照の使い分け
開発時に「ライブラリのソースを変更したら即時反映」したい場合は、paths で src/ を指す手もあります。
// services/portal/web/tsconfig.json
{
"compilerOptions": {
"paths": {
"@nagiyu/common": ["../../../libs/common/src/index.ts"],
"@nagiyu/common/*": ["../../../libs/common/src/*"]
}
}
}
ただしこれは TypeScript 型解決の話で、Next.js のランタイムでは node_modules 経由で dist/ を見ています。本番ビルド時は dist が必要なので、ライブラリのビルドを CI で先に実行する手順は省けません。
CI でのビルド順序
GitHub Actions で安全に動かすには、ライブラリ → アプリの順で動かします。
- name: Install
run: npm ci
- name: Build libraries
run: npm run build --workspace=@nagiyu/common --workspace=@nagiyu/ui
- name: Build app
run: npm run build --workspace=@nagiyu/portal-web
npm run build --workspaces で全ワークスペースを順に動かす方法もありますが、依存順を明示したほうが失敗時の切り分けが楽です。
Docker イメージにも持ち込む
サービス単体を Docker イメージ化する場合、モノレポ全体をコピーしてからインストールするか、ビルド済み成果物だけ COPY するかを選びます。Next.js standalone と組み合わせると後者が綺麗です。
# stage 1: monorepo 全体をビルド
FROM node:24-alpine AS builder
WORKDIR /repo
COPY package.json package-lock.json ./
COPY libs/ ./libs/
COPY services/portal/ ./services/portal/
RUN npm ci
RUN npm run build --workspace=@nagiyu/common --workspace=@nagiyu/ui
RUN npm run build --workspace=@nagiyu/portal-web
# stage 2: standalone のみ取り出し
FROM node:24-alpine AS runner
WORKDIR /app
COPY --from=builder /repo/services/portal/web/.next/standalone ./
COPY --from=builder /repo/services/portal/web/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
Next.js standalone は依存ライブラリを node_modules ごとパッケージしてくれるので、最終イメージは数十 MB に収まります。
実装ノート
記事のサンプルでは workspaces を ["libs/*", "services/*/web", "services/*/api"] と簡単に書きましたが、nagiyu-platform のルート package.json では実際にはもっと多く、services/tools・services/*/core・services/*/web・services/*/web-*・services/*/batch・services/*/batch-*・services/*/lambda/*・services/*/api・libs/*・infra・infra/* を並べています。Web だけでなく batch・lambda・CDK インフラ(infra)まで 1 つの依存ツリーに入れているので、npm ci 一発で全サービス分が揃うのは個人で多サービスを抱える私にとってかなり効いています。
ライブラリ間の依存方向は ui → browser → common の一方向に保ち、循環参照を禁止しています。各 libs は * バージョンで参照させてシンボリックリンク解決させていますが、Next.js 側では tsconfig の paths で src/ を直接指すのではなく、next.config.ts の transpilePackages に @nagiyu/ui・@nagiyu/browser・@nagiyu/common・@nagiyu/nextjs を列挙して取り込む方式に落ち着きました。Portal の tsconfig の paths は @/* のアプリ内エイリアスだけに留めています。
ハマったポイント
実際にこのモノレポを回すなかで自分が踏んだポイントを残しておきます。
- package-lock.json は必ずルート 1 個: 各サブディレクトリに lock ファイルを作らない。重複が出たら片方を削除。
workspace:*プロトコルは npm 未対応: pnpm / yarn では使えるが npm ではエラー。私は*を使っています。- ビルド順序を仕組みで担保する: Portal の
buildスクリプトはnpm run build --workspace=@nagiyu/nextjs && next build --webpackで、アプリ本体の前に共有の@nagiyu/nextjsを必ずビルドさせています。これを忘れると型定義が古いまま参照されて事故ります。 - TypeScript の Project References:
tsc --buildで順序を指定する別仕組み。workspaces と併用できるが学習コストが上がる。私は最初は入れませんでした。 - package-lock.json の noisy diff:
optionalDependenciesの OS 依存フィールドが揺れる。CI ではnpm ciを使い、ローカルnpm installの差分は警戒する。 - 共通パッケージのバージョン整合:
reactなどを libs と app で別バージョン入れると Hooks エラー。ルートのdependenciesでreact/next/ MUI を一括管理しています。
現在の運用
私は今このモノレポで Portal を含む複数サービスを並行運用していますが、共通の悩みである「依存バージョンの揺れ」をルートの overrides でまとめて吸収しています。fast-xml-parser や axios(>=1.15.0)などをルートで固定しておくと、各サービスが個別に古い推移的依存を抱える事故が減りました。実行環境も engines で Node >=24 / npm >=10 に固定し、全サービスで前提を揃えています。
ビルド順序も実地で固めました。前述のとおり Portal の build は @nagiyu/nextjs を先にビルドしますし、CI 側でも shared workspaces をカンマ区切りで先に回す composite action を用意して、ライブラリ → アプリの順序を仕組みとして担保しています。手元でもこの前提を守る限り、複数サービスを 1 リポジトリで独立してデプロイできています。
まとめ
npm workspaces でのモノレポ構成は、複数サービスを開発・運用する個人開発・小規模チームに向いた選択肢です。共通ライブラリの分離・パッケージ間参照・ビルド順序・Docker での取り回しを整えれば、コード重複を減らしつつ各サービスを独立してデプロイできます。