【npm workspace】モノレポ構成のプロジェクトでアプリケーションを相互に連携する

# はじめに

こんにちは、梅雨です。

最近ではバックエンドに TypeScript を採用し、フロントエンドとE2Eで型安全に開発を行うことがきでるようになってきています。

今回は、npm を用いたモノレポ構成のプロジェクトでアプリケーション同士を上手く連携する方法について解説していこうと思います。

# プロジェクト構成

この記事では以下のようなプロジェクト構成を例にして説明を進めていきます。各スタックは基本的になんでも構いませんが、今回はサーバに Express 、クライアントには Next.js を使用します。

```bash
monorepo-workspace-demo
├── apps
│   ├── client
│   └── server
└── packages
    └── ui
```

# ルートの npm init

まずはプロジェクトのルートディレクトリで `npm init` を実行します。

```bash
$ npm init
```

`package.json` の中身は以下の内容で充分です。ポイントとしては、各ワークスペースの `package.json` のあるディレクトを `workspaces` として指定しているところです。

これによって、複数のワークスペースを一元的に管理できるようになります。

```json:package.json
{
  "name": "monorepo-workspace-demo",
  "workspaces": [
    "apps/client",
    "apps/server",
    "packages/*"
  ]
}
```

:::note
`packages/` 以下には今後いろいろなパッケージが作成される可能性があり、その度に `workspaces` を編集するのは効率が悪いため、ワイルドカードを用いて `packages/*` のように指定しています。
:::

# 各ワークスペースの設定

各ワークスペースには、`package.json` を配置する必要があります。管理される側の `package.json` では、特別な設定は必要ありません。

例として、サーバの `package.json` は以下のようにしました。

```json:apps/server/package.json
{
  "name": "server",
  "scripts": {
    "build": "tsc",
    "dev": "nodemon src/index.ts",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "express": "^4.21.2"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "nodemon": "^3.1.9",
    "ts-node": "^10.9.2",
    "typescript": "^5.7.2"
  }
}
```

# 依存パッケージのインストール

各パッケージ（またはアプリケーション）で必要なライブラリをインストールするには、

1. 各ディレクトリに移動して `npm i`
2. ルートディレクトリで `npm i -w client`（ワークスペースを指定） 

の２通りの方法があります。せっかく npm workspace を使用しているので、自分は後者をおすすめします。

ここで一つ注意点があり、npm workspace で管理されている各ワークスペースには `node_modules` はインストールされず、代わりにルートディレクトにインストールされます。また、`package-lock.json` もルートディレクトのみに作成されます。

そのため、`npx create-next-app@latest` コマンドなどでアプリケーションを初期化している場合は、そのプロジェクト内の `node_modules` および `package-lock.json` は手動で消してあげる必要があります。

ルートディレクトリで `npm i` および `npm list` を実行してみましょう。以下のように各ワークスペースでの依存関係が表示されるはずです。

```bash
$ npm i
$ npm list
monorepo-workspace-demo@ /Users/meiyu/Documents/qiita/monorepo-workspace-demo
├─┬ client@ -> ./apps/client
│ ├── @eslint/eslintrc@3.2.0
│ ├── @types/node@20.17.10
│ ├── @types/react-dom@19.0.2
│ ├── @types/react@19.0.1
│ ├── eslint-config-next@15.1.0
│ ├── eslint@9.17.0
│ ├── next@15.1.0
│ ├── postcss@8.4.49
│ ├── react-dom@19.0.0
│ ├── react@19.0.0
│ ├── tailwindcss@3.4.16
│ └── typescript@5.7.2
├─┬ server@ -> ./apps/server
│ ├── @types/express@5.0.0
│ ├── express@4.21.2
│ ├── nodemon@3.1.9
│ ├── ts-node@10.9.2
│ └── typescript@5.7.2 deduped
└─┬ ui@1.0.0 -> ./packages/ui
  ├── @types/react@19.0.1 deduped
  └── react@19.0.0 deduped
```

# パッケージ間でコードを呼び出す

最後に、パッケージ間でコードの呼び出しを行ってみましょう。

まずは `packages/ui` に以下のようなシンプルなボタンコンポーネントを作成してみます。

```tsx:packages/ui/button.tsx
import { ComponentPropsWithoutRef } from "react";

type ButtonProps = {
  title: string;
} & ComponentPropsWithoutRef<"button">;

export const Button = ({ title, ...buttonProps }: ButtonProps) => {
  return <button {...buttonProps}>{title}</button>;
};
```

`tsconfig.json` は以下のように指定しています。

```json:packages/ui/tsconfig.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "CommonJS",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "jsx": "react-jsx",
    "outDir": "./dist",
    "declaration": true
  }
}
```

また、`package.json` は以下のようになっています。

```json:packages/ui/package.json
{
  "name": "ui",
  "main": "./dist/index.ts",
  "scripts": {
    "build": "tsc"
  },
  "devDependencies": {
    "@types/react": "^19.0.1",
    "react": "^19.0.0"
  }
}
```

配布用のエンドポイントとして、`./dist/index.ts` を指定しました。このファイルは、外部にエクスポートするコンポーネントを統合する役割があります。

```ts:packages/ui/src/index.ts
export * from "./button";
```

以上で `ui` パッケージの準備ができました。`npm run build` コマンドで配布用にビルドしましょう。

```bash
$ npm run build -w ui
```

クライアント（Next.js）でこのコンポーネントを呼び出しましょう。

```tsx:apps/client/src/page.tsx
import { Button } from "ui";

const Home = () => {
  return (
    <div>
      <Button title="Click" />
    </div>
  );
};

export default Home;
```

これで上手くいくはず（少なくとも VSCode 上では上手くいっている）のですが、残念ながらクライアントで `npm run dev` を実行したところエラーが発生しました。

```bash
 ⨯ ./src/app/page.tsx:1:1
Module not found: Can't resolve 'ui'
> 1 | import { Button } from "ui";
    | ^
  2 |
  3 | const Home = () => {
  4 |   return (
```

仕方ないので、クライアントの `tsconfig.json` に以下の設定を追記しました。

```diff_json:apps/client/src/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"],
+       "ui": ["../../packages/ui/src"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
```

これでは元も子もないような気がしますが、一旦これで上手くいきました。

別の解決策があればコメントで教えてください。

# おわりに

今回の記事では、npm を用いたモノレポ構成のプロジェクトでアプリケーション同士を上手く連携する方法について解説しました。

TypeScript の強みである型安全性を最大限享受するためには各ワークスペース間の連携が必要不可欠であるため、ぜひ以上の内容を参考にしてみてください。