DEV Community

Nazmul Ahasan
Nazmul Ahasan

Posted on Originally published at nazmul-ahasan.pro.bd

Next.js basePath 404 Nginx: Running Two Next.js Apps on One Domain

Next.js basePath 404 Nginx: Running Two Next.js Apps on One Domain

If you've ever hit a Next.js basePath 404 behind Nginx, you already know the feeling: the HTML loads, the page looks half-alive, and every script and stylesheet comes back 404. I ran into that, and three other problems, while getting two Next.js apps to live under one domain behind a single Nginx server. This post covers all four, what was actually happening in each case, and the config I'd use now.

Some context. I'm Nazmul Ahasan, a Senior Engineering Manager in Platform Engineering. I write about this kind of thing on my site, Nazmul Ahasan. At FundedNext I picked up the "Help Me Choose My Plan" AI wizard when it was an MVP about 10% done and took it to about 90%. It's live in production now, and roughly $1M a month in plan purchases go through it. That's why I care about the plumbing. When that much checkout traffic goes through a page, a broken asset path is not a cosmetic bug.

The wizard is one of two Next.js apps behind one Nginx domain. Everything below uses generic examples: example.com, an app at /app-a, the wizard at /wizard, and the loopback address 127.0.0.1, and upstream ports 3000 and 3001. Those are placeholders, not our real setup.

The setup: two Next.js apps, one Nginx

The shape is simple:

  • https://example.com/app-a/... → Next.js app A on 127.0.0.1:3000 (example port)
  • https://example.com/wizard/... → the wizard on 127.0.0.1:3001 (example port)

Both apps are built and run on their own. Nginx owns the domain and routes by path prefix. On paper it's a ten-line config. In practice, every one of my four war stories comes from the gap between "Nginx routes by prefix" and "Next.js assumes it owns the root."

War story 1: the Next.js basePath 404 behind Nginx

What's actually going on

By default a Next.js app thinks it lives at /. Its HTML points at assets like /_next/static/chunks/main-abc123.js. Put two apps on one domain and both of them want /_next/static. Nginx can only send that path to one upstream, so the other app's assets 404. Or worse, they get answered by the wrong app.

basePath fixes this by making Next.js own its prefix completely. Pages, links, and static assets all move under it, so the wizard's assets become /wizard/_next/static/... and stop colliding with app A.

I hit a basePath 404 on the wizard. The symptom of this class of bug is consistent: the HTML loads, then requests under /_next/static fail, so the page shows up unstyled and dead. The usual culprit is one of three things. basePath isn't set, basePath doesn't match the Nginx location, or Nginx strips the prefix before the request reaches the app. The check I'd run first is the Network tab. Take the exact URL of a failing chunk and ask whether it carries the prefix the app was built with.

The next.config.js

// next.config.js for the wizard app (example values)
/** @type {import('next').NextConfig} */
const nextConfig = {
  // Every route and every /_next asset now lives under /wizard.
  basePath: '/wizard',

  // Leave assetPrefix alone unless static assets come from a different
  // origin (a CDN, say). basePath already prefixes /_next/static.
  // assetPrefix: 'https://cdn.example.com/wizard',
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

App A gets basePath: '/app-a' the same way.

Two things trip people up here. First, basePath is applied at build time. Change it and you have to rebuild; restarting the server isn't enough. Second, basePath and assetPrefix are different jobs. basePath moves the whole app. assetPrefix only changes where the browser fetches /_next/static from. I've seen people set both to /wizard and wonder why asset URLs come out wrong. If you serve everything from the same origin, basePath alone is enough.

Also, hardcoded paths don't get rewritten for you. next/link and next/image respect basePath. A raw <img src="/logo.png"> or fetch('/api/plans') does not. That's a quieter version of the same 404.

The Nginx side

This is where the classic bug lives:

# WRONG: the trailing slash on proxy_pass strips /wizard/ from the request
location /wizard/ {
    proxy_pass http://127.0.0.1:3001/;
}
Enter fullscreen mode Exit fullscreen mode

When proxy_pass includes a URI (even just /), Nginx replaces the matched location prefix with it. A request for /wizard/_next/static/chunks/x.js reaches Next.js as /_next/static/chunks/x.js. But the app was built with basePath: '/wizard' and expects the prefix, so it returns 404. Leave the URI off and Nginx forwards the path untouched:

# Example config: example.com, ports 3000/3001 are placeholders
upstream app_a  { server 127.0.0.1:3000; }
upstream wizard { server 127.0.0.1:3001; }

server {
    listen 443 ssl http2;
    server_name example.com;

    proxy_http_version 1.1;
    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;

    # --- Wizard (basePath: '/wizard') ---
    # Hashed build assets: safe to cache forever.
    location /wizard/_next/static/ {
        proxy_pass http://wizard;          # no URI: keep /wizard in the path
        proxy_hide_header Cache-Control;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    location = /wizard { return 301 /wizard/; }

    location /wizard/ {
        proxy_pass http://wizard;
        proxy_hide_header Cache-Control;
        add_header Cache-Control "no-store" always;
    }

    # --- App A (basePath: '/app-a') ---
    location /app-a/_next/static/ {
        proxy_pass http://app_a;
        proxy_hide_header Cache-Control;
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    location = /app-a { return 301 /app-a/; }

    location /app-a/ {
        proxy_pass http://app_a;
        proxy_hide_header Cache-Control;
        add_header Cache-Control "no-store" always;
    }
}
Enter fullscreen mode Exit fullscreen mode

The rule I follow now: the path Nginx forwards must still contain the basePath. If you really want Nginx to strip the prefix, then don't use basePath. Pick one place to own the prefix, not both.

One caveat on the /_next/static blocks. Next.js already sends immutable for those files in production, so the explicit header is belt and braces. I keep it so the intent is visible in Nginx.

War story 2: Next.js hydration not working in production

What a corrupt build looks like

This one is nastier than a 404 because the page looks fine. Server-rendered HTML arrives, the layout is right, the text is right. Then nothing responds. Buttons and inputs do nothing. The client never took over.

Hydration is React attaching event handlers and state to HTML the server already rendered. To do that, the browser needs the exact JavaScript chunks that build produced. If the HTML comes from one build and the chunks come from another, or the chunks on disk are incomplete, hydration fails. Common ways to get there:

  • A build interrupted mid-write, leaving .next partly stale.
  • An incremental build reusing a cached .next that doesn't match the current source or dependencies.
  • Rolling or multi-instance deploys where instances run different builds, so HTML from instance one asks for chunks that only instance two has.
  • Stale HTML cached somewhere pointing at chunks that no longer exist (more on that in story 3).

I hit this with a corrupt build. The usual culprit is a stale or partial .next directory, and the typical first symptom is a page that renders but ignores every click.

How to spot it

  1. Open the console first. Look for ChunkLoadError, Loading chunk ... failed, 404s on /_next/static/chunks/*.js in the Network tab, or React hydration errors such as "Hydration failed because the initial UI does not match what was rendered on the server." A page that renders but has no interactivity and no errors is rare. Usually something is shouting.
  2. Compare build IDs. Every Next.js build gets an ID, written to .next/BUILD_ID on the server. With the Pages Router you can read the one the HTML was rendered with from the page:
   // In the browser console (Pages Router)
   window.__NEXT_DATA__.buildId
Enter fullscreen mode Exit fullscreen mode

Then on each server instance:

   cat .next/BUILD_ID
Enter fullscreen mode Exit fullscreen mode

If they differ, or differ between instances behind the same upstream, you've found your mismatch. With the App Router there's no __NEXT_DATA__. Compare the hashed chunk URLs in the HTML source against what's actually in .next/static on the server.

  1. Do a clean rebuild. Don't trust the cache when you're debugging this:
   rm -rf .next
   npm ci
   npm run build
Enter fullscreen mode Exit fullscreen mode

If a clean build fixes it, the problem was the build artifact, not your code.

What fixes it, nearly every time, is the clean rebuild.

My opinion: make clean builds the default in CI, and make sure every instance serving a basePath runs the same BUILD_ID. Build caching speeds things up until it hands you a production incident that costs more than all the minutes it ever saved.

War story 3: HTML cached for a year

I hit this one too: HTML getting cached for a year. Here's how it usually happens. Someone writes one broad caching rule like "cache everything under this prefix for a year", because the static assets deserve it. But HTML also lives under that prefix. Now the browser, or any cache in between, holds a year-old HTML document. That document points at hashed chunk filenames from a build that's long gone.

Hashed assets and HTML have opposite caching needs:

  • /_next/static/* filenames contain a content hash. A new build means new filenames, so the old file never changes. max-age=31536000, immutable is correct.
  • HTML has a stable URL (/wizard/) but its contents change with every deploy. Cache it for a year and you pin users to an old build. That old build's chunks may 404 after a deploy, and you're back in story 2: a page that renders and doesn't hydrate.

I switched the HTML to no-store. no-cache is also valid. It lets caches store the response but forces revalidation every time. For a checkout-adjacent flow I'd rather pay for a fresh render than reason about revalidation behavior across every proxy in the path.

The usual culprit is a single Nginx location or CDN rule that applies a long max-age to a whole path prefix. The check I'd run first is curl -I on the HTML URL itself, not on an asset.

Next.js headers() vs Nginx

You might reach for headers() in next.config.js:

// next.config.js — fine for most custom headers
module.exports = {
  basePath: '/wizard',
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [{ key: 'X-Frame-Options', value: 'DENY' }],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

That works for headers like X-Frame-Options, but be careful with Cache-Control. Next.js docs warn that Cache-Control values set this way can be overwritten in production for pages and static assets. Next manages those itself. For dynamically rendered pages Next already sends private, no-cache, no-store, max-age=0, must-revalidate. For static or ISR pages it sends s-maxage values tied to revalidation. So for a hard guarantee I enforce Cache-Control at Nginx, as in the config above: proxy_hide_header Cache-Control to drop the upstream value, then add_header ... always so it also applies to error responses.

One Nginx gotcha worth knowing: add_header directives are inherited from the parent block only if the child block defines none of its own. Add a single add_header inside a location, and every server-level add_header (security headers included) silently disappears for that location. Repeat them, or put them in an include file.

Then verify, don't assume:

curl -sI https://example.com/wizard/ | grep -i cache-control
curl -sI https://example.com/wizard/_next/static/chunks/<some-chunk>.js | grep -i cache-control
Enter fullscreen mode Exit fullscreen mode

War story 4: resetting wizard state with a key

A multi-step wizard holds a lot of local state: current step, answers, derived recommendations. At some point you need a real "start over". The instinct is to write a reset() that sets every piece of state back to its initial value. That works until someone adds a new useState and forgets to add it to reset(). Now "start over" leaves one stale field behind.

React already has a cleaner tool. A component's identity is its position in the tree plus its key. Change the key and React treats it as a different component. It unmounts the old instance, throwing away all its state, effects, and refs, and mounts a fresh one with initial state everywhere.

'use client';
import { useState } from 'react';

function PlanWizard({ onRestart }) {
  const [step, setStep] = useState(0);
  const [answers, setAnswers] = useState({});
  // ...every other piece of state, no manual reset needed

  return (
    <div>
      {/* steps... */}
      <button onClick={onRestart}>Start over</button>
    </div>
  );
}

export default function WizardPage() {
  const [runId, setRunId] = useState(0);

  // Bumping the key remounts PlanWizard with completely fresh state.
  return <PlanWizard key={runId} onRestart={() => setRunId((n) => n + 1)} />;
}
Enter fullscreen mode Exit fullscreen mode

This resets everything inside PlanWizard, including state you'll add next year. The trade-offs are worth knowing. Effects run cleanup and fire again on remount, so any fetch on mount happens again. State that lives outside the component (a context above it, a global store, localStorage, URL query params) is not reset by a key change. You still have to clear that yourself.

That's how I reset the wizard's state: a new key, a clean remount. The usual trigger for this pattern is an explicit "start over", or a change of context that makes the previous answers meaningless. If you use it, check whether anything outside the component also holds wizard state, and clear it in the same handler.

What I'd tell myself before starting

Running two Next.js apps behind one Nginx domain is a reasonable setup. It just punishes vague ownership. The rules I work by now:

  • One owner per prefix. Either basePath in Next.js or prefix rewriting in Nginx, never both. Keep proxy_pass free of a URI when using basePath.
  • Builds are artifacts, not suggestions. Clean builds in CI, one BUILD_ID per deployed version, and check the console before you check the code.
  • Cache by content type, not by prefix. Hashed assets get immutable for a year. HTML gets no-store or no-cache. Enforce it where you can actually guarantee it, and verify with curl -I.
  • Let React do resets. A changed key is the most complete reset you can write, because you don't write it.

None of these are exotic. The trap is that each failure shows up somewhere other than its cause.

I lead platform engineering for fintech. Read the full case study here or see how we can work together at Nazmul Ahasan.

Top comments (1)

Collapse
 
jun_liu_ca219b73a8f1489b5 profile image
jun liu •

Solid writeup on the Next.js basePath issue. I hit this exact problem last month when deploying two Next.js apps behind nginx on the same domain. The key insight is that nginx needs to pass through the full path including the basePath — your config with the extra location block is correct. One addition: if you are using Docker, make sure the container port matches what nginx is proxying to. A silent misconfig there caused me to spend an hour before realizing the upstream was returning 404 because the app was not handling the base path prefix.