[Next.js] standalone モードのビルドで不要なパッケージが混入する問題とその回避方法
はじめに
Next.js にはstandalone モードというのがあって、これを使うと本番環境デプロイに必要なファイルだけを抜き出し、自己完結型の standalone フォルダが生成されます。その中の next start
に代わる軽量の server.js
を実行するだけで Next.js のアプリを起動することができます。これは Docker との相性が良く、マルチステージビルドでイメージを作成する際に、standalone フォルダを最終的なイメージにコピーし、server.js
を実行するように書いておくだけで済むので、イメージサイズが小さくできるなど便利な機能です。 Vercel 以外の AWS や GCP などで運用する場合は、このモードで Docker イメージを作成して利用するケースが多いのかなと思います。
問題点
ただ、このモードでビルドした際にイメージの中に devDependencies などの不要なパッケージが大量に含まれていることに気づきました。ただし自分の場合は pnpm を使用しており、他のツールでどうなるのかまでは調査していません。 pnpm の場合は standalone モードでビルドすると .next/standalone/node_modules/.pnpm
に npm パッケージが格納されます。ここに本番実行に必要なパッケージのみが入ってると思いきや、実は不要なパッケージが紛れ込むことがあるようです。これについて調べたところいくつかイシューも上がっていました。
-
Unused dependencies in standalone build using storybook · Issue #65444 · vercel/next.js
-
Additional "dev" dependencies in standalone output node_modules · vercel/next.js · Discussion #59127
実際の症状
たとえば以下のようなパッケージ構成だったとします。
{
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"engines": {
"node": "22.14.0"
},
"packageManager": "pnpm@10.5.1",
"dependencies": {
"next": "15.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.1",
"@storybook/addon-links": "^9.1.6",
"@storybook/addon-onboarding": "^9.1.6",
"@storybook/nextjs": "^9.1.6",
"@types/node": "20.4.5",
"@types/react": "^19.0.0",
"storybook": "^9.1.6",
"typescript": "5.1.3",
"@storybook/addon-docs": "^9.1.6"
}
}
next.config
の output
には standalone
を指定します。
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
これで pnpm build
を実行すると、出力は以下のようになります。
.next/standalone/node_modules/.pnpm $ ls -1
@esbuild+darwin-arm64@0.20.2
@img+sharp-darwin-arm64@0.34.3
@img+sharp-libvips-darwin-arm64@1.2.0
@jridgewell+gen-mapping@0.3.5
@jridgewell+resolve-uri@3.1.2
@jridgewell+set-array@1.2.1
@jridgewell+source-map@0.3.6
@jridgewell+sourcemap-codec@1.4.15
@jridgewell+trace-mapping@0.3.25
@next+env@15.5.3
@swc+helpers@0.5.15
@webassemblyjs+ast@1.12.1
@webassemblyjs+floating-point-hex-parser@1.11.6
@webassemblyjs+helper-api-error@1.11.6
@webassemblyjs+helper-buffer@1.12.1
@webassemblyjs+helper-numbers@1.11.6
@webassemblyjs+helper-wasm-bytecode@1.11.6
@webassemblyjs+helper-wasm-section@1.12.1
@webassemblyjs+ieee754@1.11.6
@webassemblyjs+leb128@1.11.6
@webassemblyjs+utf8@1.11.6
@webassemblyjs+wasm-edit@1.12.1
@webassemblyjs+wasm-gen@1.12.1
@webassemblyjs+wasm-opt@1.12.1
@webassemblyjs+wasm-parser@1.12.1
@xtuc+ieee754@1.2.0
@xtuc+long@4.2.2
acorn-import-assertions@1.9.0_acorn@8.11.3
acorn@8.11.3
ajv-keywords@3.5.2_ajv@6.12.6
ajv@6.12.6
browserslist@4.23.0
buffer-from@1.1.2
caniuse-lite@1.0.30001741
chrome-trace-event@1.0.3
client-only@0.0.1
color-convert@2.0.1
color-name@1.1.4
color-string@1.9.1
color@4.2.3
detect-libc@2.0.4
electron-to-chromium@1.4.757
enhanced-resolve@5.16.0
es-module-lexer@1.5.2
esbuild@0.20.2
eslint-scope@5.1.1
esrecurse@4.3.0
estraverse@4.3.0
estraverse@5.3.0
fast-deep-equal@3.1.3
fast-json-stable-stringify@2.1.0
glob-to-regexp@0.4.1
graceful-fs@4.2.11
has-flag@4.0.0
is-arrayish@0.3.2
jest-worker@27.5.1
json-parse-even-better-errors@2.3.1
json-schema-traverse@0.4.1
loader-runner@4.3.0
merge-stream@2.0.0
mime-db@1.52.0
mime-types@2.1.35
nanoid@3.3.8
neo-async@2.6.2
next@15.5.3_@babel+core@7.24.5_react-dom@19.1.1_react@19.1.1__react@19.1.1
node_modules
node-releases@2.0.14
picocolors@1.1.1
postcss@8.4.31
randombytes@2.1.0
react-dom@19.1.1_react@19.1.1
react@19.1.1
schema-utils@3.3.0
semver@7.7.2
serialize-javascript@6.0.2
sharp@0.34.3
simple-swizzle@0.2.2
source-map-js@1.2.1
source-map-support@0.5.21
source-map@0.6.1
styled-jsx@5.1.6_@babel+core@7.24.5_react@19.1.1
supports-color@8.1.1
tapable@2.2.1
terser-webpack-plugin@5.3.10_esbuild@0.20.2_webpack@5.91.0_esbuild@0.20.2_
terser@5.31.0
typescript@5.1.3
uri-js@4.4.1
watchpack@2.4.1
webpack-sources@3.2.3
webpack@5.91.0_esbuild@0.20.2
不要なものが多く含まれていますね。例えば typescript, @webassemblyjs は不要ですし、esbuild は Storybook で使われているため巻き込まれているようです。特に esbuild は golang で作られているのでそれ関連の脆弱性がときどき含まれていたりします。(CVE 関連のイシュー)
こういった不要なパッケージがあると、イメージサイズも無駄に増えますし脆弱性にも繋がるかもしれません。あとは GCP の Artifact Registry の脆弱性スキャンなどで検知されたりして、フロントエンドで go は使ってないのになんか go の CVE が混ざってるけど(?)みたいな変なことが起きたりします。
Docker イメージでも確認してみる
Dockerfile
# ------------------------------------------------------------
#
# pnpmインストールステージ
#
# ------------------------------------------------------------
FROM node:22-trixie-slim AS pnpm_stage
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# packageManager に指定されたパッケージマネージャをインストール
RUN npm install -g $(node -p "JSON.parse(fs.readFileSync('./package.json', 'utf-8')).packageManager")
# ------------------------------------------------------------
#
# パッケージインストールステージ
#
# ------------------------------------------------------------
FROM pnpm_stage AS pkg_install_stage
WORKDIR /app
# https://pnpm.io/ja/cli/fetch
# `next build`プロセスで devDependencies の内容も必要なため --prod オプションを付けない
RUN pnpm fetch
RUN pnpm install --offline
# ------------------------------------------------------------
#
# ビルドステージ
#
# ------------------------------------------------------------
FROM pnpm_stage AS builder_stage
WORKDIR /app
COPY . .
COPY /app/node_modules ./node_modules
COPY /app/pnpm-lock.yaml ./
COPY /app/package.json ./
RUN pnpm build
# ------------------------------------------------------------
#
# コンテナ起動ステージ
#
# ------------------------------------------------------------
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
ENV NODE_ENV=production
COPY /app/public ./public
COPY /app/package.json ./package.json
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
USER nonroot
EXPOSE 3000
ENV PORT=3000
CMD ["server.js"]
Docker Desktop でイメージの中身を確認すると、やはり不要なパッケージが入ってますね。 esbuild が使っている golang のパッケージに CVE があり、 Vulnerabilities が検知されてます。
回避方法
Next.js には必要なファイルを含められなかったり、未使用のファイルを誤って含めてしまう場合に備えて、next.config.js でそれぞれ outputFileTracingExcludes
と outputFileTracingIncludes
が用意されています。
outputFileTracingExcludes
を指定することでビルドの出力から除外できます。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
outputFileTracingExcludes: {
"*": ["esbuild", "webassemblyjs","typescript"],
},
};
export default nextConfig;
これで pnpm build
を実行した結果
.next/standalone/node_modules/.pnpm $ ls -1
@img+sharp-darwin-arm64@0.34.3
@img+sharp-libvips-darwin-arm64@1.2.0
@next+env@15.5.3
@swc+helpers@0.5.15
caniuse-lite@1.0.30001741
client-only@0.0.1
color-convert@2.0.1
color-name@1.1.4
color-string@1.9.1
color@4.2.3
detect-libc@2.0.4
is-arrayish@0.3.2
nanoid@3.3.8
next@15.5.3_@babel+core@7.24.5_react-dom@19.1.1_react@19.1.1__react@19.1.1
node_modules
picocolors@1.1.1
postcss@8.4.31
react-dom@19.1.1_react@19.1.1
react@19.1.1
semver@7.7.2
sharp@0.34.3
simple-swizzle@0.2.2
source-map-js@1.2.1
styled-jsx@5.1.6_@babel+core@7.24.5_react@19.1.1
outputFileTracingExcludes
で指定したものが除外されてだいぶ減りましたね。
Docker Desktop で確認したイメージの中身です。
以下のあたりが改善されてます。
-
COPY --chown=nonroot:nonroot /app/.next/standalone ./ # buildkit
- 126.61MB → 87.7MB
- Vulnerabilities
- 21 → 0
- Packages
- 220 → 149
- イメージサイズ
- 386.94MB → 337.22MB
コンテナーを起動して Next.js の起動が成功することも一応確認します。
$ docker run -p 3000:3000 test-nextjs-standalone-build
▲ Next.js 15.5.3
- Local: http://4ac2d38fd92f:3000
- Network: http://4ac2d38fd92f:3000
✓ Starting...
✓ Ready in 181ms
@storybook/nextjs
と @storybook/nextjs-vite
で出力結果が違う
Storybook の ちなみに Storybook v9 から @storybook/nextjs-vite
のフレームワークを推奨するようになったようです。
with-vite
Storybook recommends using the @storybook/nextjs-vite framework, which is based on Vite and removes the need for Webpack and Babel. It supports all of the features documented here.
実は先ほどのケースでは@storybook/nextjs
を使用していました。
@storybook/nextjs-vite
を使うようにパッケージを更新してみます。
{
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"engines": {
"node": "22.14.0"
},
"packageManager": "pnpm@10.5.1",
"dependencies": {
"next": "15.5.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.1",
"@storybook/addon-docs": "^9.1.6",
"@storybook/addon-links": "^9.1.6",
"@storybook/addon-onboarding": "^9.1.6",
- "@storybook/nextjs": "^9.1.6",
+ "@storybook/nextjs-vite": "^9.1.6",
"@types/node": "20.4.5",
"@types/react": "^19.0.0",
"storybook": "^9.1.6",
"typescript": "5.1.3"
}
}
- import type { StorybookConfig } from '@storybook/nextjs';
+ import type { StorybookConfig } from '@storybook/nextjs-vite';
const config: StorybookConfig = {
...
"framework": {
- "name": '@storybook/nextjs',
+ "name": '@storybook/nextjs-vite',
"options": {}
},
...
};
export default config;
こんどは next.config.ts
の outputFileTracingExcludes
をコメントアウトしておきます。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
// outputFileTracingExcludes: {
// "*": ["esbuild", "webassemblyjs","typescript"],
// },
};
export default nextConfig;
これで pnpm build
を実行すると下記のような出力になりました。
(一度 node_modules と .next ディレクトリを削除してからインストールし直したほうが良いと思います。変更前のキャッシュが残っていると不要なパッケージが入ってしまうようなので。)
.next/standalone/node_modules/.pnpm $ ls -1
@img+sharp-darwin-arm64@0.34.3
@img+sharp-libvips-darwin-arm64@1.2.0
@next+env@15.5.3
@swc+helpers@0.5.15
buffer-from@1.1.2
caniuse-lite@1.0.30001741
client-only@0.0.1
color-convert@2.0.1
color-name@1.1.4
color-string@1.9.1
color@4.2.3
detect-libc@2.0.4
is-arrayish@0.3.2
nanoid@3.3.8
next@15.5.3_@babel+core@7.28.4_react-dom@19.1.1_react@19.1.1__react@19.1.1
node_modules
picocolors@1.1.1
postcss@8.4.31
react-dom@19.1.1_react@19.1.1
react@19.1.1
semver@7.7.2
sharp@0.34.3
simple-swizzle@0.2.2
source-map-js@1.2.1
source-map-support@0.5.21
source-map@0.6.1
styled-jsx@5.1.6_@babel+core@7.28.4_react@19.1.1
typescript@5.1.3
相変わらずtypescript
は含まれていますが、それ以外のesbuild
やwebassemblyjs
などの不要なパッケージは無くなっていました。このようにパッケージの組み合わせなどによって不要なパッケージの巻き込まれ方が異なるようです。
最後に
自動で完璧にやってくれると一番いいのですが、なかなかきれいに不要なパッケージを除外するのが難しいという事情もあるのかもしれません。そういった時のために outputFileTracingExcludes
が用意されているようなので、standalone モードで運用する場合はビルドして出力された.next/standalone
内のパッケージに不要なものが混ざっていないかどうか確認するようにしておくのが無難そうです。
Discussion