Next.js と AWS CDK を npm workspaces を使って Monorepo 構成にしてみた

## はじめに

Monorepo 構成を試して見るべく、[npm workspaces](https://docs.npmjs.com/cli/v10/using-npm/workspaces) を使って、以下のような 3 パッケージ構成を試してみました。

```
[ルートディレクトリ]
├── [web パッケージ]: Next.js を使った web アプリ
├── [infra パッケージ]: AWS CDK を使った IaC コード（Lambda アプリ含め）
└── [共有パッケージ]: web, infra パッケージから利用される共有コード
```

web パッケージと infra パッケージで利用する共通的な処理やオブジェクトなどを、共有パッケージに配置して利用する構成を作成します。各パッケージのセットアップ方法、共有パッケージへの参照方法を実際に試してみます。

作成したソース一式は以下に配置していますので、詳細はリポジトリのソースを確認してください。

https://github.com/ore88ore/monorepo-web-iac-example

### 実行環境

- Node.js: `v22.14.0`
- npm: `10.9.2`

## セットアップ

セットアップは空の状態から、まずはルートパッケージを作成し、web パッケージ、infra パッケージ、共有パッケージをセットアップします。

### ルートパッケージを作成

```
npm init
```

```shell
% tree
.
└── package.json
```

### web パッケージを作成

```shell
npm init -w ./packages/web
```

```shell
% tree                      
.
├── node_modules
│   └── web -> ../packages/web
├── package-lock.json
├── package.json
└── packages
    └── web
        └── package.json
```

#### Next.js プロジェクトをセットアップ

作成した web パッケージに Next.js のプロジェクトをセットアップします。
create-next-app コマンドは空のディレクトリにする必要があったので、`packages/web/package.json` ファイルを削除してからセットアップコマンドを実行しました。

```shell
npx create-next-app@latest ./packages/web/.
```

セットアップが完了したら、起動することを確認します。

```shell
npm run -w web dev

> web@0.1.0 dev
> next dev --turbopack

   ▲ Next.js 15.3.1 (Turbopack)
   - Local:        http://localhost:3000
   - Network:      http://192.168.0.136:3000

 ✓ Starting...
 ✓ Ready in 562ms
```

問題なく起動することを確認できました。ルートディレクトリからパッケージ内の npm scripts を実行する際は、`-w` コマンドでワークスペースを指定して実行することができます。`--workspace=web` でも同様に実行することができます。

https://docs.npmjs.com/cli/v10/commands/npm-run-script#workspaces-support

### infra パッケージを作成

```shell
npm init -w ./packages/infra
```

```shell
% tree
.
├── node_modules
│   ├── infra -> ../packages/infra
│   └── web -> ../packages/web
├── package-lock.json
├── package.json
└── packages
    ├── infra
    │   └── package.json
    └── web
        └── package.json
```

#### AWS CDK プロジェクトをセットアップ

作成した infra パッケージに AWS CDK をセットアップします。

```shell
cd ./packages/infra
cdk init app --language typescript
```

デプロイする前に、bootstrap します。

```shell
npm run -w infra cdk -- bootstrap

> infra@0.1.0 cdk
> cdk bootstrap

...
 ✅  Environment aws://xxxxxxxx/ap-northeast-1 bootstrapped.
```

成功しましたので、AWS CDK のセットアップは問題なさそうですね。

### 共有（shared）パッケージを作成

```shell
npm init -w ./packages/shared
```

```shell
% tree                         
.
├── node_modules
│   ├── infra -> ../packages/infra
│   ├── shared -> ../packages/shared
│   └── web -> ../packages/web
├── package-lock.json
├── package.json
└── packages
    ├── infra
    │   └── package.json
    ├── shared
    │   └── package.json
    └── web
        └── package.json
```

## 共有パッケージに実装した処理を各パッケージから参照する

共有パッケージに、ID を生成する処理を実装しました。

```typescript:packages/shared/src/id.ts
import { v4 as uuidv4 } from "uuid";

/**
 * 新しいUUIDを生成する
 * @returns 生成されたUUID文字列
 */
export function generateUuid(): string {
  return uuidv4();
}
```

このファンクションを `shared` パッケージ経由で import させるために、package.json や tsconfig 設定も修正しました。

```typescript: packages/shared/src/index.ts
export * from "./id";
```

```text: packages/shared/package.json
... 省略
"main": "dist/index.js",
"types": "dist/index.d.ts",
... 省略
```

```text: packages/shared/tsconfig.json
... 省略
{
  "compilerOptions": {
    "outDir": "./dist",
... 省略
```

また、共有パッケージを `shared` パッケージ経由でアクセスさせる場合は、build しないと反映されないので、修正後は build するか、`tsc --watch` のように修正後すぐに build されるようにしておく必要があります。
※ TypeScript の [Project References](https://www.typescriptlang.org/docs/handbook/project-references.html) など試してみたが、うまくいかず・・・。よりよいやり方があったら追記します。

### infra パッケージの Lambda から参照する

実装した ID を生成する処理を Lambda function から参照します。

```typescript: packages/infra/lambda/uuid-generator.ts
import { generateUuid } from "shared";  // ここで共有パッケージの処理を import

export const handler = async () => {
  const id = generateUuid();
  console.log("Generated UUID:", id);

  return {
    statusCode: 200,
    body: JSON.stringify({
      id,
    }),
  };
};
```

実装した Lambda を CDK でデプロイできるように、[NodejsFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs-readme.html) でリソースを作成します。

```typescript: packages/infra/lib/infra-stack.ts
export class InfraStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new nodejs.NodejsFunction(this, "UuidGeneratorFunction", {
      entry: path.join(__dirname, "../lambda/uuid-generator.ts"),
      handler: "handler",
      functionName: "uuid-generator",
    });
  }
}
```

これでデプロイした Lambda を実行して、正しく動くかを確認してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/6460d192-8963-4efa-a052-3f72c5b0a9c1.png)

意図していた通り実行できることを確認できました！


### web パッケージの Next.js アプリから参照する

Next.js の Web ページに、共有パッケージの ID 生成処理を利用して、画面に表示する処理を実装します。

```tsx: packages/web/app/page.tsx
import { generateUuid } from "shared";  // ここで共有パッケージの処理を import
import { UuidClient } from "./components/UuidClient";

export default function Home() {
  // サーバーサイドで初期UUIDを生成
  const uuid = generateUuid();
  return (
    <main className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-br from-blue-50 to-blue-200 dark:from-gray-900 dark:to-blue-950">
      <UuidClient initialUuid={uuid} />
    </main>
  );
}
```

```tsx: packages/web/app/components/UuidClient.tsx
"use client";
import { useState } from "react";

export function UuidClient({ initialUuid }: { initialUuid: string }) {
  const [currentUuid, setCurrentUuid] = useState(initialUuid);
  const [loading, setLoading] = useState(false);
  const handleRegenerate = async () => {
    setLoading(true);
    const res = await fetch("/api/generate-uuid");
    const data = await res.json();
    setCurrentUuid(data.uuid);
    setLoading(false);
  };
  return (
    <div className="flex flex-col items-center gap-8">
      <div className="text-xl font-bold text-black dark:text-black p-10 rounded-xl shadow-lg bg-white/90 dark:bg-black/70 border-2 border-blue-200 dark:border-blue-700 transition-all flex flex-col items-center gap-4">
        <span>生成したIDは</span>
        <span className="text-blue-700 dark:text-blue-400 text-4xl font-mono font-extrabold select-all break-all tracking-wide">
          {currentUuid}
        </span>
        <span>です</span>
      </div>
      <button
        className="px-8 py-3 rounded-lg bg-blue-600 text-white font-semibold text-lg shadow hover:bg-blue-700 transition disabled:opacity-50"
        onClick={handleRegenerate}
        disabled={loading}
      >
        {loading ? "再生成中..." : "IDを再生成"}
      </button>
    </div>
  );
}
```

ローカルで起動して、正しく実行できているかを試してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/eeb2d751-c625-42d8-957f-f5601dbbeb55.png)

意図した動きになってそうですね！

## Tips

さいごに、環境構築時や動作確認時に知っておくと便利な Tips を紹介します。

### 依存関係を追加する

すべてのパッケージで利用可能な依存関係をインストール

```shell
npm install prettier
```

個別のパッケージに依存関係をインストール（オプションでワークスペース情報を指定する）

```shell
npm install prettier -w web
```

単一パッケージの時のようにディレクトリに移動してもOK

```shell
cd packages/web && npm install prettier
```

なお、`node_modules` は、ルートパッケージに作成され、各パッケージのディレクトリには、バージョン違いなど異なるパッケージが存在する場合に作成されます。

### scripts を実行する

`npm run` コマンドで実行する scripts も、依存関係を追加する場合と同様にワークスペースを指定して実行することができます。

```shell
npm run build -w shared
```

### VSCode のクイックフィックスから参照できない

環境や設定の問題かもしれませんが、VSCode で `shared` パッケージの関数を利用しようとした場合のサジェストの選択肢やクイックフィックスでの import の選択肢に出ませんでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/b03fa0af-a83e-4d6a-be3f-ed362f370644.png)

build 時は、参照できないなどの問題はなかったのですが、実装時に手入力は面倒なので、package.json に `shared` パッケージの `dependencies` を指定することで、解消することができました。

```text: packages/infra/package.json
"dependencies": {
    ...,
    "shared": "*"
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/331769/5bcc97d8-4feb-4c03-b4fe-2c838efea4ad.png)

なお、IntelliJ IDEA だと、問題なく参照できました。

### CDK パッケージの型参照設定

`cdk init` コマンドで初期化したプロジェクトの場合、以下のように tsconfig.json に [typeRoots](https://www.typescriptlang.org/tsconfig/#typeRoots) が設定されています。

```text:tsconfig.json
    "typeRoots": [
      "./node_modules/@types"
    ]
```

この設定の場合、指定されたパスの配下のパッケージのみが参照の対象となります。（`../node_modules/@types/` や `../../node_modules/@types/` が含まれない）
Monorepo 構成の場合は、巻き上げでモジュールを解決するため、typeRoot を指定しないほうが良さそうですね。

### Next.js を standalone でビルドする際の出力パス

Next.js アプリを `standalone` オプションでビルドする場合、通常のプロジェクトとは異なり、今回作成した Monorepo 環境では出力パスが変わるようでした。

```text
.next/standalone
├── node_modules
│   ├── ...
├── package.json
└── packages
    ├── shared
    │   └── package.json
    └── web
        ├── package.json
        └── server.js    // packages/web 配下に出力されている
```

わかっていれば特に問題はありませんが、[公式サンプルの Docker ビルドコマンド](https://github.com/vercel/next.js/tree/canary/examples/with-docker) などをそのまま利用しようとすると、パスが異なるので、環境に合わせて修正する必要があります。

## 参考

https://docs.npmjs.com/cli/v10/using-npm/workspaces

https://github.com/ore88ore/monorepo-web-iac-example