
Next.js + FastAPI を nginx サブパスで配信するときに踏んだ3つの落とし穴 — trailingSlash・ルーティング分離・MuleSoftのSSEバッファリング
はじめに
Next.js(フロントエンド)+ FastAPI(バックエンド)のモノレポ構成のアプリを、nginxのサブパスで配信する構成で、3つのルーティング関連の落とし穴にハマりました。
basePathだけでは404になる —trailingSlashの罠- フロントエンドとバックエンドのルーティング分離 — nginxで2つのサービスを振り分ける
- API GatewayがSSEをバッファリングする — MuleSoft経由でストリーミングが止まる
いずれも解決策自体はシンプルですが、問題の切り分けに時間がかかったものばかりです。同じ構成で悩む方の参考になればと思い、nginxの基礎やAPI Gatewayの役割も交えて解説します。
前提・環境
| 項目 | 値 |
|---|---|
| Next.js | 16(App Router) |
| FastAPI | 0.115+ |
| Nginx | 1.24 |
| Docker Compose | あり(ポートマッピング使用) |
| MuleSoft | Anypoint Platform(CloudHub) |
アーキテクチャ

フロントエンドとバックエンドはそれぞれDockerコンテナで動作し、docker-compose.override.yml でホストポートを割り当てています。
# docker-compose.override.yml
services:
frontend:
ports:
- "40001:3000"
backend:
ports:
- "40002:8765"
nginxとは何か — そもそもなぜ必要なのか
「Next.jsには開発サーバーがあるのに、なぜ前段にnginxが必要なの?」という疑問を持つ方もいると思います。
nginxはリバースプロキシ・Webサーバーです。主な役割は以下の通りです。
| 役割 | 説明 |
|---|---|
| リバースプロキシ | クライアントからのリクエストを背後のアプリケーションサーバーに転送する |
| パスベースルーティング | URLパスに応じて異なるサービスに振り分ける(/app-a/ → ポート3000、/app-b/ → ポート5000) |
| SSL終端 | HTTPSの暗号化・復号をnginxが担い、背後のアプリはHTTPで通信すればよい |
| 静的ファイル配信 | HTML/CSS/JS/画像をアプリサーバーを経由せず直接返す |
| 負荷分散 | 複数のアプリサーバーにリクエストを分散する |
今回のケースでは、1台のサーバーで40以上のアプリを共存させるため、nginx のパスベースルーティングが必須でした。
https://host/app-a/ → localhost:3000 (React)
https://host/app-b/ → localhost:5000 (Streamlit)
https://host/your-app/ → localhost:40001 (Next.js) ← 今回のアプリ
...(全部で40以上のlocation)
Next.jsだけでは足りないのか?
Next.jsの next start は本番対応のHTTPサーバーです。 Next.js単体でアプリを配信すること自体は問題ありません。 実際、VercelやAWS AmplifyなどのプラットフォームではnginxなしでNext.jsが稼働しています。
ただし、以下のケースではnginxが必要になります。
- 複数アプリの共存 — 1つのドメイン・IPで複数サービスをサブパスで配信する場合
- SSL終端の一元管理 — 各アプリが個別にSSL証明書を管理するのは非現実的
- 既存のnginxインフラとの統合 — すでにnginxで管理されているサーバーにアプリを追加する場合
逆に言えば、専用ドメインで1つのアプリだけを配信するなら、nginxは不要です。Next.jsの next start だけで十分です。
落とし穴 1: basePath だけでは nginx サブパスが 404 になる
設定
// next.config.ts
const nextConfig: NextConfig = {
basePath: "/your-app",
};
location /your-app/ {
proxy_pass http://localhost:40001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 120s;
client_max_body_size 20m;
}
上記の location ブロックで使用しているディレクティブを整理します。
プロキシ転送
| ディレクティブ | 値 | 役割 |
|---|---|---|
proxy_pass |
http://localhost:40001/ |
リクエストを転送する先のURL。末尾の / が重要 — これにより location のプレフィックス(/your-app/)が除去されてから転送される(後述) |
proxy_http_version |
1.1 |
バックエンドとの通信にHTTP/1.1を使用する。デフォルトは1.0だが、1.1にしないと Connection: keep-alive やWebSocketの Upgrade が機能しない |
ヘッダー転送
| ディレクティブ | 値 | 役割 |
|---|---|---|
proxy_set_header Host |
$host |
元のリクエストの Host ヘッダーをそのまま背後のサーバーに渡す。これがないとnginxのホスト名(localhost)が送られ、アプリ側でURLの生成やリダイレクトが壊れる |
proxy_set_header X-Real-IP |
$remote_addr |
クライアントの実IPアドレスを背後のサーバーに伝える。プロキシ経由だとリクエスト元がnginxのIPになるため、ログや認証でクライアントIPが必要な場合に設定する |
proxy_set_header X-Forwarded-For |
$proxy_add_x_forwarded_for |
プロキシチェーン全体のIPアドレスをカンマ区切りで記録する。X-Real-IP が直近のクライアントIPだけを渡すのに対し、こちらは多段プロキシでも経路を追跡できる |
proxy_set_header X-Forwarded-Proto |
$scheme |
元のリクエストが http と https のどちらだったかを背後のサーバーに伝える。SSLをnginxで終端している場合、アプリがリダイレクトURLを生成する際にプロトコルを正しく判定するために必要 |
proxy_set_header Upgrade |
$http_upgrade |
WebSocket接続のアップグレードヘッダーを転送する。Next.jsの開発時HMR(Hot Module Replacement)で使用される |
proxy_set_header Connection |
"upgrade" |
Upgrade ヘッダーと組み合わせて、HTTPからWebSocketへのプロトコル切り替えを有効にする |
バッファリング・タイムアウト・ボディサイズ
| ディレクティブ | 値 | 役割 |
|---|---|---|
proxy_buffering |
off |
nginxがバックエンドのレスポンスをバッファせず、受け取ったデータをすぐにクライアントへ送る。SSEストリーミングで必須 |
proxy_read_timeout |
120s |
バックエンドからのレスポンスを待つ最大時間。デフォルトは60sだが、AI推論のように応答に時間がかかるリクエストではタイムアウトが発生するため延長する |
client_max_body_size |
20m |
クライアントから受け付けるリクエストボディの最大サイズ。デフォルトは1MBだが、ファイルアップロードがある場合は引き上げる必要がある |
特に proxy_pass の末尾スラッシュは見落としやすいポイントです。
# ✅ /your-app/settings/ → localhost:40001/settings/(プレフィックス除去)
location /your-app/ {
proxy_pass http://localhost:40001/;
}
# ❌ /your-app/settings/ → localhost:40001/your-app/settings/(プレフィックスが残る)
location /your-app/ {
proxy_pass http://localhost:40001;
}
症状
| URL | 結果 |
|---|---|
https://host/your-app/ |
正常に表示される |
https://host/your-app |
404 |
末尾スラッシュありなら表示されるが、スラッシュなしだと404になります。ブラウザのアドレスバーに打つときやブックマークではスラッシュなしが自然なため、ほぼ全員がこの404に遭遇していました。
原因
nginxの location /your-app/ はトレイリングスラッシュ付きのURLにのみマッチします。/your-app(スラッシュなし)がリクエストされた場合、nginxは try_files でディレクトリ判定を行いますが、Next.jsは静的ファイルを配信しているわけではないため、ディレクトリとして解釈できず404を返します。
問題はNext.jsの出力側にありました。basePath だけではNext.jsがトレイリングスラッシュを付加しないのです。
修正
const nextConfig: NextConfig = {
basePath: "/your-app",
+ trailingSlash: true,
};
この設定により、Next.jsが生成するすべてのURLに自動でトレイリングスラッシュが付きます。
- ルートページ:
/your-app/ - 各ページ:
/your-app/settings/ - APIルート:
/your-app/api/chat/
nginx側の変更は不要です。
なぜ気づきにくいか
- ローカル開発(
pnpm dev)では問題が起きない — Next.jsの開発サーバーは自前でルーティングを処理するため、トレイリングスラッシュの有無を問わず正しくレスポンスを返す basePathの公式ドキュメントにtrailingSlashとの組み合わせが明記されていない — それぞれ独立した設定項目として記載されており、nginxサブパス配信時にセットで必要になることが分かりにくい- nginx側の設定ミスに見える — 404が返るため「
locationの書き方が間違っているのでは」と疑い、nginx設定を何度も変更してしまう
SSHトンネルで検証する
デプロイ先が閉域ネットワーク上にある場合、ローカルブラウザからnginxを通してアクセスを検証できます。SSHトンネルでデプロイ先のnginx(port 80)をローカルに転送するスクリプトを用意しました。
pnpm nginx # localhost:8081 → デプロイ先:80(nginx経由)
直接接続とnginx経由の両方でアクセスし、差分を比較します。
| 確認方法 | URL | 結果 |
|---|---|---|
| 直接接続 | http://localhost:8080/your-app |
表示される |
| nginx経由 | http://localhost:8081/your-app |
404 |
| nginx経由 | http://localhost:8081/your-app/ |
表示される |
この差分から「nginx経由でスラッシュなしだけ404」→「Next.js側のURL出力が原因」と特定できました。
落とし穴 2: フロントエンドとバックエンドのルーティング分離
問題
「/your-app/v1/healthz にアクセスしたら404が返る」という問題が発生しました。
このエンドポイントはFastAPIのバックエンドに実装されていますが、nginxはすべてのリクエストをフロントエンド(Next.js) に転送していました。
# 当初の設定 — すべてフロントエンドへ
location /your-app/ {
proxy_pass http://localhost:40001/;
}
Next.jsは /v1/healthz を知らないので、当然404を返します。
構成の理解
このアプリは2つのサーバーで構成されています。
| サービス | ポート | 役割 |
|---|---|---|
| Frontend(Next.js) | 40001 | UI配信 + BFF(APIルート) |
| Backend(FastAPI) | 40002 | AIチャット、検索、ヘルスチェック等 |
ブラウザからのリクエストは以下の2経路があります。
- UI・BFF経路:
ブラウザ → nginx → Frontend(:40001)— HTMLページとNext.js APIルートへのアクセス - バックエンドAPI経路:
ブラウザ/外部 → nginx → Backend(:40002)— FastAPIエンドポイントへの直接アクセス
修正: nginx でパスベースの振り分け
バックエンドのすべてのエンドポイントは /v1/ プレフィックス配下に統一しました。これにより、nginxのルーティングルールがシンプルになります。
# /your-app/v1/* → バックエンド(FastAPI)
location /your-app/v1/ {
proxy_pass http://localhost:40002/v1/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 120s;
client_max_body_size 20m;
}
# /your-app/* → フロントエンド(Next.js)
location /your-app/ {
proxy_pass http://localhost:40001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 120s;
client_max_body_size 20m;
}
nginxは最長一致でlocationを評価するため、/your-app/v1/healthz は /your-app/v1/ にマッチしてバックエンドに転送され、/your-app/settings/ は /your-app/ にマッチしてフロントエンドに転送されます。
ポイント: API プレフィックスの統一
この振り分けが簡単に実装できたのは、バックエンドのすべてのエンドポイントが /v1/ 配下にあったからです。もし /healthz や /readyz がルートレベルにあると、nginxのlocationルールが複雑になります。
# FastAPI側 — すべて /v1/ 配下に統一
app.include_router(health.router, prefix="/v1") # /v1/healthz, /v1/readyz
app.include_router(chat.router, prefix="/v1") # /v1/chat
app.include_router(search.router, prefix="/v1") # /v1/search
教訓: リバースプロキシ配下で複数サービスを共存させる場合、APIプレフィックスを統一しておくとルーティングが劇的に楽になる。
Next.jsのBFF(Backend for Frontend)パターン
ここで「フロントエンドとバックエンドが別サーバーなら、ブラウザから直接バックエンドAPIを呼べばいいのでは?」と思うかもしれません。
実際には、Next.jsのAPIルートがBFF(Backend for Frontend)として仲介しています。
ブラウザ → /your-app/api/chat → Next.js APIルート → FastAPI /v1/chat
ブラウザ → /your-app/api/resources → Next.js APIルート → FastAPI /v1/resources
BFFパターンのメリット:
- CORS不要 — ブラウザは同一オリジンのNext.js APIルートにアクセスするだけ
- 認証情報の隠蔽 — API GatewayのクレデンシャルをサーバーサイドAPIルートに保持し、ブラウザに露出しない
- レスポンス加工 — バックエンドのレスポンスをフロントエンド向けに整形できる
このパターンが次の話題、API Gatewayとの統合で重要になります。
落とし穴 3: MuleSoft(API Gateway)がSSEをバッファリングする
API Gatewayとは
API Gatewayは、バックエンドAPIの前段に配置するプロキシサーバーです。nginxと似ていますが、APIの管理・セキュリティ・ガバナンスに特化しています。
| 機能 | nginx | API Gateway(MuleSoft等) |
|---|---|---|
| リバースプロキシ | ○ | ○ |
| SSL終端 | ○ | ○ |
| 認証・認可 | △(Basic認証程度) | ○(OAuth、API Key、レート制限) |
| APIカタログ・ポータル | × | ○ |
| 利用量モニタリング | × | ○ |
| OpenAPIスキーマ管理 | × | ○ |
今回の構成では、MuleSoft Anypoint PlatformをAPI Gatewayとして採用していました。外部アプリケーションがバックエンドAPIにアクセスする際、MuleSoftを経由して client_id / client_secret で認証する構成です。
構成

Next.jsのBFFルートからバックエンドを呼び出す際に、MuleSoftの認証ヘッダーを付与します。
// frontend/lib/backend-fetch.ts
export const BACKEND_URL =
process.env.BACKEND_URL || INTERNAL_BACKEND_URL;
export const mulesoftHeaders: Record<string, string> = {
...(clientId && { client_id: clientId }),
...(clientSecret && { client_secret: clientSecret }),
};
問題: SSEストリーミングが全く動かない
チャット機能はSSE(Server-Sent Events)でレスポンスをストリーミング配信しています。ローカルや直接接続では問題なく動作していましたが、MuleSoft経由にした途端、ストリーミングが止まりました。
症状:
- リクエスト送信後、22秒間何も表示されない
- 22秒後に全レスポンスが一括で到着する
- つまり、ストリーミングではなくバッチレスポンスになっている
デバッグ: TransformStream でチャンクを計測
SSEのチャンクがどのタイミングで到着しているかを可視化するため、TransformStreamを使ったデバッグログを追加しました。
// frontend/app/api/chat/route.ts
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const reader = res.body.getReader();
(async () => {
let chunk_count = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunk_count++;
const text = decoder.decode(value, { stream: true });
const events = text.match(/^event: .+$/gm);
console.log("[chat] chunk#%d +%dms events=%s bytes=%d",
chunk_count, Date.now() - t0,
events?.join(",") ?? "(none)", value.length);
await writer.write(value);
}
} finally {
writer.close();
}
})();
ログ結果(MuleSoft経由):
[chat] fetching https://mulesoft-gateway.example.com/your-app/v1/chat
[chat] response status=200 latency=22431ms
[chat] chunk#1 +22435ms events=event: token,event: token,...,event: result bytes=18562
[chat] stream ended chunks=1 total=22438ms
チャンクが1つだけ。22秒分のSSEイベントがすべて1つのチャンクにバッファリングされていました。
直接接続のログ(比較用):
[chat] fetching http://backend:8765/v1/chat
[chat] response status=200 latency=245ms
[chat] chunk#1 +248ms events=event: systems bytes=312
[chat] chunk#2 +1203ms events=event: token bytes=45
[chat] chunk#3 +1245ms events=event: token bytes=38
...
[chat] chunk#47 +8932ms events=event: result bytes=2841
[chat] stream ended chunks=47 total=8935ms
47チャンクが逐次到着しています。
原因
MuleSoftに確認したところ、以下の回答がありました。
- MuleSoftではデフォルトでレスポンスをバッファする仕様
- Content-LengthやTransfer-Encoding: chunkedなどのヘッダー情報での自動判定であり、今回はその条件を満たしていない
- 明示的にSSEパススルーを有効化することは「可能」
- ただし、リスナを他のエンドポイント(ストリーミングしないもの)と分ける必要がある
つまり、MuleSoftでSSEを通すには:
- バックエンドが適切なヘッダー(
Transfer-Encoding: chunked)を返すこと - SSE用のリスナを非SSEエンドポイントと分離すること(MuleSoftの設定変更が必要)
解決策: chatだけMuleSoftをバイパス
MuleSoft側の設定変更は工数がかかるため、chatエンドポイントだけMuleSoftを経由せず直接バックエンドに接続する方式を採用しました。
// chat — 直接接続(SSEストリーミングのため)
import { INTERNAL_BACKEND_URL } from "@/lib/backend-fetch";
const res = await fetch(`${INTERNAL_BACKEND_URL}/v1/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" }, // MuleSoftヘッダーなし
body: JSON.stringify(body),
});
// resources, actions — MuleSoft経由(通常のJSON API)
import { BACKEND_URL, mulesoftHeaders } from "@/lib/backend-fetch";
const res = await fetch(`${BACKEND_URL}/v1/resources`, {
headers: { ...mulesoftHeaders },
});
最終的なBFFルートの振り分け:
| エンドポイント | 経由 | 理由 |
|---|---|---|
/v1/chat |
直接 | SSEストリーミングが必要 |
/v1/resources |
API Gateway | 通常のJSON API |
/v1/actions |
API Gateway | 通常のJSON API |
/v1/admin/* |
直接 | 内部管理用(API Gateway未登録) |
この変更はNext.jsのBFFルート(サーバーサイド)のみの修正で、nginx側の変更は不要です。BFFパターンを採用していたおかげで、ブラウザから見たAPIは何も変わらず、バックエンドへの接続先だけを切り替えられました。
nginxとAPI Gateway、それぞれの使いどころ
ここまでの経験を踏まえて、nginx・API Gateway・BFFそれぞれの役割を整理します。
いつnginxが必要か
| シナリオ | nginx | 理由 |
|---|---|---|
| 1ドメインで複数アプリを共存 | 必要 | パスベースルーティング |
| SSL終端の一元管理 | 必要 | 各アプリが個別にSSL管理するのは非現実的 |
| 専用ドメインで1アプリだけ | 不要 | next start で十分 |
| PaaS(Vercel, Amplify等) | 不要 | プラットフォームがルーティングを担当 |
いつAPI Gatewayが必要か
| シナリオ | API Gateway | 理由 |
|---|---|---|
| 外部パートナーにAPIを公開 | 有効 | 認証・レート制限・利用量監視 |
| 社内の複数チームがAPIを共有 | 有効 | APIカタログ・バージョン管理 |
| 内部専用の管理API | 不要 | 認証・監視のオーバーヘッドが見合わない |
| SSEやWebSocketを多用 | 要検討 | バッファリングやタイムアウトの問題が発生しうる |
レイヤーの整理

各層は独立した関心事を持っています。今回のSSEバッファリング問題では、「ガバナンス層をバイパスする」という判断がBFFパターンのおかげで容易にできました。
Docker Composeのポート設計
1つのサーバーで多数のアプリを共存させるため、各チームにポートレンジを割り当て、docker-compose.override.yml でマッピングしています。
# ベースの docker-compose.yml にはポート定義を含めない
services:
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
# 環境固有の override でポートを指定
# docker-compose.override.yml
services:
frontend:
ports:
- "40001:3000" # この環境ではポート40001を使用
backend:
ports:
- "40002:8765"
docker-compose.override.yml はDocker Composeが自動的にベース設定にマージするファイルです。リポジトリにコミットしておけばデプロイ時に自動的に適用されるため、手動作成の手間が省けます。
まとめ
| 落とし穴 | 原因 | 修正 |
|---|---|---|
| サブパスで404 | basePath だけではトレイリングスラッシュが付かない |
trailingSlash: true を追加(1行) |
| バックエンドAPIが404 | nginxがすべてフロントエンドに転送していた | /v1/ プレフィックスで振り分けルールを追加 |
| SSEがバッファリングされる | MuleSoftがレスポンス全体をバッファする仕様 | SSEエンドポイントだけMuleSoftをバイパス |
3つの問題に共通するのは、ローカル開発では再現せず、デプロイ先の中間層(nginx、API Gateway)を通して初めて発覚するという点です。
対策としては:
- SSHトンネルで本番に近い経路を再現する — ローカルからnginx経由でアクセスできる環境を用意する
- デバッグログを仕込んでから切り分ける — 今回のTransformStreamによるチャンク計測のように、問題の箇所を特定するログを入れてから調査する
- APIプレフィックスを統一する — リバースプロキシ配下では
/v1/のようなプレフィックスがルーティング設計を大幅に簡素化する - BFFパターンで接続先を柔軟にする — ブラウザから見たAPIを変えずに、サーバーサイドで接続先を切り替えられる設計にしておく







