「ローカルでは動くのに」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のモノレポ構成において、以下の現象に遭遇しました。
-
npm run devでローカル開発している時は、自作パッケージ(packages/uiなど)を正常に読み込める。 - しかし、
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 内のシンボリックリンク経由でパッケージを参照しようとします。
- Next.js「お、
node_modulesからのインポートだな。ここはコンパイル済みのはずだから、トランスパイルはスキップ(無視)しよう」 - Next.js「中身を読むぞ……ん?
export const ...? JSX? TypeScriptのままじゃん!」 - Build Error: Module parse failed
つまり、シンボリックリンクの先が生のTypeScriptであるがゆえに、コンパイル除外対象になってしまいエラーになるのが真相です。
3. 解決策:transpilePackages
これを回避するには、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のコンソールで「モノレポ」として認識されているか確認し、ビルド設定を以下のように調整します。
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