😱

「ローカルでは動くのに」Next.jsモノレポ×Amplifyでハマるビルドの罠

に公開

TL;DR

  • 構造: apps も packages も兄弟。ルートの package.json ですべて管理するメンタルモデルを持つ。
  • 原因: ローカルで動くのは tsconfig のおかげかも。ビルド時は node_modules 除外ルールが厳格適用される。
  • 解決: next.config.mjs の transpilePackages で「これはただのライブラリじゃない、俺のソースコードだ」と明示する。

環境

  • Next.js: 14.x (App Router)
  • Package Manager: npm (Workspaces機能利用)
  • Deploy: AWS Amplify (Hosting)

課題:ローカルでは動くのに、Amplifyのビルドで死ぬ

Next.jsのモノレポ構成において、以下の現象に遭遇しました。

  1. npm run dev でローカル開発している時は、自作パッケージ(packages/uiなど)を正常に読み込める。
  2. しかし、npm run build を実行、またはAWS Amplifyにプッシュすると、以下のようなエラーでビルドが失敗する。
Module parse failed: Unexpected token
You may need an appropriate loader to handle this file type...
| export const Button = ({ children }: Props) => {
|   return <button>{children}</button>; // <--- ここで死ぬ
| };

「ローカルで動いているのになぜ?」
この謎を解くために、npm workspacesの仕組みとNext.jsのビルドプロセスを紐解きます。

1. メンタルモデル:アプリもパッケージも「兄弟」である

まず、ディレクトリ構成の認識を合わせます。
「メインのアプリの下に部品がある」ではなく、 「npmプロジェクトという親の下に、アプリと部品が対等に並んでいる」 状態です。

root/
├── package.json      <-- workspaces: ["apps/*", "packages/*"]
├── node_modules/     <-- すべての依存はここに集約(hoist)される
├── apps/
│   └── web/          <-- Next.jsアプリ(デプロイ対象)
└── packages/
    └── ui/           <-- 自作UIライブラリ

npm workspacesを使うと、packages/ui はルートの node_modules 内にシンボリックリンクとして配置されます。
これにより、アプリ側からは import { Button } from '@my-project/ui' のように、あたかも外部ライブラリのようにインポートが可能になります。

2. なぜ「ローカルで動き、ビルドで死ぬ」のか?

ここが最大のハマりポイントです。

バンドラーの「鉄の掟」

Next.js(Webpack/Turbopack)には、ビルドパフォーマンスを守るための基本的なルールがあります。
「node_modules の中にあるファイルは、すでにコンパイル済みのJSとみなし、トランスパイル(変換)処理をスキップする」

ローカルでの挙動(なぜ動いた?)

ローカル開発時、もし tsconfig.json の paths オプションで以下のように設定していた場合、

"paths": {
  "@my-project/ui/*": ["../../packages/ui/src/*"]
}

TSコンパイラやエディタは、node_modules を経由せず、ファイルパスを直接辿ってソースコードを読みに行きます。
結果、Next.jsはこれを「アプリ内のソースコードの一部」として認識し、自動的にコンパイルしてくれるため、動いてしまいます。

ビルド時の挙動(なぜ死ぬ?)

しかし、本番ビルドやAmplify上でのビルドプロセスでは、依存関係の解決が厳密に行われます。
ここでは package.json の定義に従い、node_modules 内のシンボリックリンク経由でパッケージを参照しようとします。

  1. Next.js「お、node_modules からのインポートだな。ここはコンパイル済みのはずだから、トランスパイルはスキップ(無視)しよう」
  2. Next.js「中身を読むぞ……ん? export const ...? JSX? TypeScriptのままじゃん!」
  3. Build Error: Module parse failed

つまり、シンボリックリンクの先が生のTypeScriptであるがゆえに、コンパイル除外対象になってしまいエラーになるのが真相です。

3. 解決策:transpilePackages

これを回避するには、next.config.mjs で「このパッケージだけは例外的にトランスパイルしてくれ」と明示する必要があります。

apps/web/next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  // node_modules内にあっても、ここにあるパッケージはソースコードとして扱ってビルドする
  transpilePackages: ['@my-project/ui'], 
};

export default nextConfig;

これを設定することで、Amplify上でも正しく自作パッケージがビルドプロセスに含まれるようになります。

4. Amplifyデプロイ時の注意点(Monorepo設定)

Amplifyでモノレポをデプロイする場合、もう一つ「ビルド設定(amplify.yml)」の罠があります。
ルートで npm install をした上で、アプリのディレクトリに移動してビルドする手順が必要です。

Amplifyのコンソールで「モノレポ」として認識されているか確認し、ビルド設定を以下のように調整します。

amplify.yml
version: 1
applications:
  - appRoot: apps/web # ここがNext.jsアプリのルート
    frontend:
      phases:
        preBuild:
          commands:
            - npm ci # ルートでのインストール(重要)
        build:
          commands:
            - npm run build
      artifacts:
        baseDirectory: .next
        files:
          - '**/*'
      cache:
        paths:
          - node_modules/**/*

まとめ

  • 現象: ローカルは paths 設定で誤魔化せているが、ビルド時は node_modules の除外ルールに引っかかる。
  • 対処: 自作パッケージは transpilePackages に必ず追加する。
  • Amplify: モノレポ設定では、ルートで依存解決 → アプリディレクトリでビルド、の流れを意識する。

「動くコード」と「動かないコード」の差分は、往々にして ツールが裏で良かれと思ってやっている最適化(node_modulesの無視など) にあります。ここを意識するとデバッグが早くなります。

Next Action

Amplifyのビルドログを見て、「Module parse failed」が出ていたら即 transpilePackages を確認してください。また、amplify.yml の preBuild でルートディレクトリでの npm ci が走っているかも要チェックです。

Discussion