Next.js + FastAPI を nginx サブパスで配信するときに踏んだ3つの落とし穴 — trailingSlash・ルーティング分離・MuleSoftのSSEバッファリング

Next.js + FastAPI を nginx サブパスで配信するときに踏んだ3つの落とし穴 — trailingSlash・ルーティング分離・MuleSoftのSSEバッファリング

Next.js + FastAPIのモノレポをnginxサブパスで配信する際に踏んだ3つの落とし穴(trailingSlash設定、フロントエンド/バックエンドのルーティング分離、MuleSoftによるSSEバッファリング)とその解決策を、デバッグ手法やBFFパターンの活用と共に解説します。
2026.06.21

はじめに

Next.js(フロントエンド)+ FastAPI(バックエンド)のモノレポ構成のアプリを、nginxのサブパスで配信する構成で、3つのルーティング関連の落とし穴にハマりました。

  1. basePath だけでは404になるtrailingSlash の罠
  2. フロントエンドとバックエンドのルーティング分離 — nginxで2つのサービスを振り分ける
  3. API GatewayがSSEをバッファリングする — MuleSoft経由でストリーミングが止まる

いずれも解決策自体はシンプルですが、問題の切り分けに時間がかかったものばかりです。同じ構成で悩む方の参考になればと思い、nginxの基礎やAPI Gatewayの役割も交えて解説します。

前提・環境

項目
Next.js 16(App Router)
FastAPI 0.115+
Nginx 1.24
Docker Compose あり(ポートマッピング使用)
MuleSoft Anypoint Platform(CloudHub)

アーキテクチャ

nextjs-fastapi-nginx-subpath-routing-pitfalls-architecture

フロントエンドとバックエンドはそれぞれ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 元のリクエストが httphttps のどちらだったかを背後のサーバーに伝える。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側の変更は不要です。

なぜ気づきにくいか

  1. ローカル開発(pnpm dev)では問題が起きない — Next.jsの開発サーバーは自前でルーティングを処理するため、トレイリングスラッシュの有無を問わず正しくレスポンスを返す
  2. basePath の公式ドキュメントに trailingSlash との組み合わせが明記されていない — それぞれ独立した設定項目として記載されており、nginxサブパス配信時にセットで必要になることが分かりにくい
  3. 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経路があります。

  1. UI・BFF経路: ブラウザ → nginx → Frontend(:40001) — HTMLページとNext.js APIルートへのアクセス
  2. バックエンド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 で認証する構成です。

構成

nextjs-fastapi-nginx-subpath-routing-pitfalls-mulesoft-bff

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を通すには:

  1. バックエンドが適切なヘッダー(Transfer-Encoding: chunked)を返すこと
  2. 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を多用 要検討 バッファリングやタイムアウトの問題が発生しうる

レイヤーの整理

nextjs-fastapi-nginx-subpath-routing-pitfalls-layers

各層は独立した関心事を持っています。今回の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を変えずに、サーバーサイドで接続先を切り替えられる設計にしておく

この記事をシェアする

関連記事