Next.js + FastAPI delivered via nginx subpath: 3 pitfalls I encountered — trailingSlash, routing separation, and MuleSoft SSE buffering

Next.js + FastAPI delivered via nginx subpath: 3 pitfalls I encountered — trailingSlash, routing separation, and MuleSoft SSE buffering

When serving a Next.js + FastAPI monorepo via an nginx subpath, I encountered three pitfalls (the trailingSlash setting, frontend/backend routing separation, and SSE buffering by MuleSoft) along with their solutions, which I will explain together with debugging techniques and the use of the BFF pattern.
2026.06.21

This page has been translated by machine translation. View original

Introduction

I ran into three routing-related pitfalls while deploying a monorepo app built with Next.js (frontend) + FastAPI (backend) on an nginx subpath.

  1. basePath alone causes 404 — the trailingSlash trap
  2. Separating frontend and backend routing — splitting two services in nginx
  3. API Gateway buffering SSE — streaming stalls when going through MuleSoft

The solutions themselves are simple, but each one took time to isolate. I'll explain them along with nginx basics and the role of API Gateway, hoping this helps others with the same setup.

Prerequisites & Environment

Item Value
Next.js 16 (App Router)
FastAPI 0.115+
Nginx 1.24
Docker Compose Yes (with port mapping)
MuleSoft Anypoint Platform (CloudHub)

Architecture

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

The frontend and backend each run in Docker containers, with host ports assigned in docker-compose.override.yml.

# docker-compose.override.yml
services:
  frontend:
    ports:
      - "40001:3000"
  backend:
    ports:
      - "40002:8765"

What is nginx — why is it needed in the first place?

You might wonder, "Next.js has a dev server, so why do we need nginx in front of it?"

nginx is a reverse proxy and web server. Its main roles are as follows.

Role Description
Reverse proxy Forwards requests from clients to application servers behind it
Path-based routing Routes to different services based on the URL path (/app-a/ → port 3000, /app-b/ → port 5000)
SSL termination nginx handles HTTPS encryption/decryption, so the backend apps only need to communicate over HTTP
Static file serving Serves HTML/CSS/JS/images directly without going through the app server
Load balancing Distributes requests across multiple app servers

In this case, over 40 apps needed to coexist on a single server, so nginx's path-based routing was essential.

https://host/app-a/  →  localhost:3000 (React)
https://host/app-b/  →  localhost:5000 (Streamlit)
https://host/your-app/ →  localhost:40001 (Next.js) ← this app
...(40+ locations in total)

Isn't Next.js alone enough?

Next.js's next start is a production-ready HTTP server. Serving an app with Next.js alone is perfectly fine. In fact, Next.js runs without nginx on platforms like Vercel and AWS Amplify.

However, nginx becomes necessary in the following cases.

  • Multiple apps coexisting — serving multiple services on the same domain/IP via subpaths
  • Centralized SSL termination — having each app manage its own SSL certificates is impractical
  • Integration with existing nginx infrastructure — adding a new app to a server already managed by nginx

Conversely, if you're serving just one app on a dedicated domain, nginx is not needed. next start alone is sufficient.

Pitfall 1: basePath alone causes 404 on nginx subpath

Configuration

// 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;
}

Here is a breakdown of the directives used in the location block above.

Proxy forwarding

Directive Value Role
proxy_pass http://localhost:40001/ The URL to forward requests to. The trailing / matters — it strips the location prefix (/your-app/) before forwarding (explained below)
proxy_http_version 1.1 Use HTTP/1.1 for communication with the backend. The default is 1.0, but without 1.1, Connection: keep-alive and the WebSocket Upgrade won't work

Header forwarding

Directive Value Role
proxy_set_header Host $host Passes the original request's Host header to the backend as-is. Without this, nginx's hostname (localhost) is sent, breaking URL generation and redirects on the app side
proxy_set_header X-Real-IP $remote_addr Passes the client's real IP address to the backend. When going through a proxy, the request origin appears as nginx's IP, so this is needed when logs or authentication require the client IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for Records the full proxy chain IP addresses as comma-separated values. Unlike X-Real-IP, which only passes the nearest client IP, this enables tracing the route even through multi-hop proxies
proxy_set_header X-Forwarded-Proto $scheme Tells the backend whether the original request was http or https. Required when SSL is terminated at nginx, so the app can correctly determine the protocol when generating redirect URLs
proxy_set_header Upgrade $http_upgrade Forwards the WebSocket upgrade header. Used for Next.js's HMR (Hot Module Replacement) during development
proxy_set_header Connection "upgrade" Combined with the Upgrade header, enables protocol switching from HTTP to WebSocket

Buffering, timeout, and body size

Directive Value Role
proxy_buffering off nginx sends data to the client immediately without buffering the backend's response. Required for SSE streaming
proxy_read_timeout 120s Maximum time to wait for a response from the backend. The default is 60s, but should be extended for requests that take longer to respond, such as AI inference
client_max_body_size 20m Maximum size of the request body accepted from clients. The default is 1MB, but must be increased when file uploads are involved

The trailing slash in proxy_pass is especially easy to overlook.

# ✅ /your-app/settings/ → localhost:40001/settings/ (prefix stripped)
location /your-app/ {
    proxy_pass http://localhost:40001/;
}

# ❌ /your-app/settings/ → localhost:40001/your-app/settings/ (prefix retained)
location /your-app/ {
    proxy_pass http://localhost:40001;
}

Symptom

URL Result
https://host/your-app/ Displays correctly
https://host/your-app 404

The page displays with a trailing slash, but without a slash it's a 404. Since typing in the browser's address bar or using bookmarks naturally omits the trailing slash, nearly everyone hit this 404.

Cause

nginx's location /your-app/ only matches URLs with a trailing slash. When /your-app (no slash) is requested, nginx tries to determine whether it's a directory via try_files, but since Next.js isn't serving static files, it can't be interpreted as a directory, and nginx returns a 404.

The issue was on the Next.js output side. With only basePath, Next.js does not append a trailing slash.

Fix

 const nextConfig: NextConfig = {
   basePath: "/your-app",
+  trailingSlash: true,
 };

With this setting, Next.js automatically appends a trailing slash to all generated URLs.

  • Root page: /your-app/
  • Individual pages: /your-app/settings/
  • API routes: /your-app/api/chat/

No changes are needed on the nginx side.

Why it's hard to notice

  1. No issue occurs during local development (pnpm dev) — Next.js's dev server handles routing on its own, responding correctly regardless of whether a trailing slash is present
  2. The official basePath docs don't mention the combination with trailingSlash — they're documented as independent settings, making it unclear that both are needed together when serving via an nginx subpath
  3. It looks like an nginx misconfiguration — since a 404 is returned, you suspect the location block is wrong and end up repeatedly tweaking the nginx config

Verifying with an SSH tunnel

If the deployment target is on a closed network, you can verify access through nginx using a local browser. I prepared a script that forwards nginx (port 80) on the deployment target to local via an SSH tunnel.

pnpm nginx  # localhost:8081 → deployment target:80 (via nginx)

Access both directly and via nginx, and compare the differences.

Method URL Result
Direct http://localhost:8080/your-app Displays
Via nginx http://localhost:8081/your-app 404
Via nginx http://localhost:8081/your-app/ Displays

This difference pinpointed the issue: "only 404 without slash via nginx" → "root cause is Next.js's URL output."

Pitfall 2: Separating frontend and backend routing

Problem

A problem arose where accessing /your-app/v1/healthz returned a 404.

This endpoint is implemented in the FastAPI backend, but nginx was forwarding all requests to the frontend (Next.js).

# Original config — everything goes to the frontend
location /your-app/ {
    proxy_pass http://localhost:40001/;
}

Since Next.js has no knowledge of /v1/healthz, it naturally returns a 404.

Understanding the architecture

This app consists of two servers.

Service Port Role
Frontend (Next.js) 40001 UI serving + BFF (API routes)
Backend (FastAPI) 40002 AI chat, search, health checks, etc.

Browser requests follow two paths.

  1. UI/BFF path: Browser → nginx → Frontend(:40001) — access to HTML pages and Next.js API routes
  2. Backend API path: Browser/External → nginx → Backend(:40002) — direct access to FastAPI endpoints

Fix: path-based routing in nginx

All backend endpoints were unified under the /v1/ prefix. This keeps nginx routing rules simple.

# /your-app/v1/* → backend (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/* → frontend (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;
}

Since nginx evaluates locations by longest match, /your-app/v1/healthz matches /your-app/v1/ and is forwarded to the backend, while /your-app/settings/ matches /your-app/ and is forwarded to the frontend.

Key point: unifying the API prefix

This routing was easy to implement because all backend endpoints were under /v1/. If /healthz or /readyz existed at the root level, the nginx location rules would become more complex.

# FastAPI side — all unified under /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

Lesson: when coexisting multiple services under a reverse proxy, unifying the API prefix makes routing dramatically simpler.

Next.js BFF (Backend for Frontend) pattern

At this point you might think, "if the frontend and backend are on separate servers, why not just call the backend API directly from the browser?"

In practice, Next.js API routes act as a BFF (Backend for Frontend) intermediary.

Browser → /your-app/api/chat → Next.js API route → FastAPI /v1/chat
Browser → /your-app/api/resources → Next.js API route → FastAPI /v1/resources

Benefits of the BFF pattern:

  • No CORS needed — the browser only accesses Next.js API routes on the same origin
  • Hiding credentials — API Gateway credentials are held in server-side API routes and never exposed to the browser
  • Response transformation — backend responses can be shaped for the frontend

This pattern becomes important in the next topic: integration with API Gateway.

Pitfall 3: MuleSoft (API Gateway) buffering SSE

What is an API Gateway?

An API Gateway is a proxy server placed in front of backend APIs. It's similar to nginx, but specialized for API management, security, and governance.

Feature nginx API Gateway (MuleSoft, etc.)
Reverse proxy
SSL termination
Authentication/Authorization △ (Basic auth only) ○ (OAuth, API Key, rate limiting)
API catalog/portal ×
Usage monitoring ×
OpenAPI schema management ×

In this setup, MuleSoft Anypoint Platform was adopted as the API Gateway. External applications access the backend API through MuleSoft, authenticating with client_id / client_secret.

Architecture

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

MuleSoft authentication headers are added when calling the backend from Next.js BFF routes.

// 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 }),
};

Problem: SSE streaming doesn't work at all

The chat feature streams responses via SSE (Server-Sent Events). It worked fine locally and with direct connections, but streaming stopped completely as soon as MuleSoft was introduced.

Symptoms:

  • After sending a request, nothing appears for 22 seconds
  • After 22 seconds, the entire response arrives all at once
  • In other words, it became a batch response instead of streaming

Debugging: measuring chunks with TransformStream

To visualize when SSE chunks were arriving, I added debug logging using 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();
  }
})();

Log result (via 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

Only one chunk. Twenty-two seconds' worth of SSE events were all buffered into a single chunk.

Direct connection log (for comparison):

[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 chunks arrive sequentially.

Cause

When we consulted MuleSoft, we received the following response.

  • MuleSoft buffers responses by default
  • Auto-detection is based on headers like Content-Length and Transfer-Encoding: chunked, and this case didn't meet those conditions
  • Explicitly enabling SSE passthrough is "possible"
  • However, the listener must be separated from other endpoints (non-streaming ones)

In other words, to pass SSE through MuleSoft:

  1. The backend must return the appropriate headers (Transfer-Encoding: chunked)
  2. The SSE listener must be isolated from non-SSE endpoints (requires changes to MuleSoft configuration)

Solution: bypass MuleSoft for chat only

Since changes to the MuleSoft configuration would require considerable effort, we adopted the approach of connecting the chat endpoint directly to the backend without going through MuleSoft.

// chat — direct connection (for SSE streaming)
import { INTERNAL_BACKEND_URL } from "@/lib/backend-fetch";

const res = await fetch(`${INTERNAL_BACKEND_URL}/v1/chat`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },  // no MuleSoft headers
  body: JSON.stringify(body),
});
// resources, actions — via MuleSoft (regular JSON APIs)
import { BACKEND_URL, mulesoftHeaders } from "@/lib/backend-fetch";

const res = await fetch(`${BACKEND_URL}/v1/resources`, {
  headers: { ...mulesoftHeaders },
});

Final BFF route routing:

Endpoint Via Reason
/v1/chat Direct SSE streaming required
/v1/resources API Gateway Regular JSON API
/v1/actions API Gateway Regular JSON API
/v1/admin/* Direct Internal management (not registered in API Gateway)

This change only required modifying Next.js BFF routes (server-side), with no changes to nginx. Thanks to the BFF pattern, the API as seen from the browser remained unchanged, and only the backend connection target needed to be switched.

When to use nginx vs. API Gateway

Based on this experience, let me summarize the roles of nginx, API Gateway, and BFF.

When nginx is needed

Scenario nginx Reason
Multiple apps on one domain Needed Path-based routing
Centralized SSL termination Needed Having each app manage SSL individually is impractical
Single app on a dedicated domain Not needed next start is sufficient
PaaS (Vercel, Amplify, etc.) Not needed The platform handles routing

When an API Gateway is needed

Scenario API Gateway Reason
Exposing APIs to external partners Effective Authentication, rate limiting, usage monitoring
APIs shared across multiple internal teams Effective API catalog, version management
Internal-only management APIs Not needed Authentication/monitoring overhead not worth it
Heavy use of SSE or WebSocket Consider carefully Buffering and timeout issues may occur

Layer summary

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

Each layer has independent concerns. In the SSE buffering problem, the BFF pattern made it easy to decide to "bypass the governance layer."

Docker Compose port design

To coexist many apps on a single server, port ranges are assigned to each team and mapped in docker-compose.override.yml.

# Base docker-compose.yml does not include port definitions
services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile.frontend

# Environment-specific override specifies ports
# docker-compose.override.yml
services:
  frontend:
    ports:
      - "40001:3000"  # use port 40001 in this environment
  backend:
    ports:
      - "40002:8765"

docker-compose.override.yml is a file that Docker Compose automatically merges with the base configuration. Committing it to the repository means it is applied automatically at deploy time, saving the effort of manual creation.

Summary

Pitfall Cause Fix
404 on subpath basePath alone doesn't add trailing slash Add trailingSlash: true (one line)
Backend API 404 nginx was forwarding everything to the frontend Add routing rule that splits on /v1/ prefix
SSE gets buffered MuleSoft's behavior of buffering the entire response Bypass MuleSoft for SSE endpoints only

What all three problems have in common is that they don't reproduce in local development and only manifest when going through intermediate layers (nginx, API Gateway) in the deployment environment.

Countermeasures:

  • Reproduce a production-like path with an SSH tunnel — set up an environment where you can access via nginx locally
  • Add debug logs before isolating the issue — insert logs to identify the problem area, like the TransformStream chunk measurement used here
  • Unify API prefixes — under a reverse proxy, a prefix like /v1/ greatly simplifies routing design
  • Use the BFF pattern for flexible connection targets — design the system so the connection target can be switched server-side without changing the API as seen from the browser

Share this article