Deploying Next.js under a sub-path: basePath gotchas we hit in production
Running a Next.js app at /tools/auth-admin instead of / looks like a one-line config change. In production, it broke the post-login redirect, links to static files and environment handling. Here is what basePath does and doesn't prefix, the bug that sent users to the wrong URL after login, and the configuration that finally made it boring.
The problem
Behind a reverse proxy at a sub-path, the admin panel sent users to the wrong URL after login and broke links to static files.
The result
One environment flag drives basePath everywhere, redirects and static links include the prefix, and the app runs as a standalone Docker image next to other services.
Stack
The problem
Our auth admin panel isn't deployed on its own domain. It lives on a shared host behind a reverse proxy, next to several other applications, each under its own path:
https://backend.company.com/tools/auth-api/ ← the auth backend
https://backend.company.com/tools/auth-admin/ ← this admin panel
https://backend.company.com/tools/… ← other apps
Next.js supports this with one setting, basePath. Locally, everything ran at / and worked perfectly. In production, a few things went wrong right away:
- after logging in, users landed on
https://backend.company.com/applications, a page that doesn't exist, instead of…/tools/auth-admin/applications, - links to files in
public/(like downloadable guides) returned 404, - the app needed to know whether it was in production in the browser too, not just at build time.
None of these show up in development, because in development there's no prefix.
The details
What basePath does
// next.config.ts
const nextConfig: NextConfig = {
basePath: '/tools/auth-admin',
};
With this, Next.js:
- serves every page under the prefix (
/applicationsbecomes/tools/auth-admin/applications), - prefixes
<Link href="/applications">automatically, - prefixes
router.push('/applications')automatically, - serves its own assets (
/_next/…) under the prefix.
That covers most of an app, which is exactly why the remaining cases are so surprising.
What basePath doesn't do
basePath only rewrites URLs that go through Next.js's routing helpers. Anything you build yourself as a string stays exactly as you wrote it:
- plain
<a href="/guides/file.md">to files inpublic/, window.location.href = …assignments,- URLs you send to another service, like a
returnToparameter for a login redirect, fetch('/something')to your own routes.
The challenges
The post-login redirect
Login isn't an API call: the browser is redirected to the auth backend, through the company's identity provider, and then sent back to a returnTo address that the panel provides:
const returnTo = window.location.origin + config.loginReturnPath;
window.location.href = `${config.apiUrl}/auth/login?returnTo=${encodeURIComponent(returnTo)}`;
loginReturnPath was "/applications". Next.js never sees that string. It's sent to another service, which redirects the browser to https://backend.company.com/applications: no prefix, wrong app, 404.
Static files
The integration guides are Markdown files in public/guides/, linked for download. A plain <a href="/guides/integration-nestjs.md"> points to the root of the host, not the app.
Environment in the browser
Whether the prefix applies depends on the environment (none locally, the sub-path in production). next.config.ts runs at build time on the server, but the browser code also needs to know the prefix to build correct URLs, and in Next.js only variables prefixed with NEXT_PUBLIC_ reach the browser.
A shared host
The app runs next to other services, so it can't assume it owns port 3000, and the proxy must forward the sub-path correctly.
The solution
1. One flag, read in both places
// next.config.ts
const appEnv = process.env.NEXT_PUBLIC_APP_ENV || 'production'; // no value → production
const nextConfig: NextConfig = {
output: 'standalone',
...(appEnv === 'production' && { basePath: '/tools/auth-admin' }),
};
// lib/config.ts: the only file that reads process.env
// Mirrors APP_ENV from next.config.ts. This file runs in the browser,
// and only NEXT_PUBLIC_-prefixed variables are visible there.
const appEnv = process.env.NEXT_PUBLIC_APP_ENV || 'production';
export const config = {
/** Mirrors basePath in next.config.ts. Next prefixes <Link> and the router,
* but not plain <a href> to files in public/, so those add it. */
basePath: appEnv === 'production' ? '/tools/auth-admin' : '',
/** Where the browser lands after a successful login. Sent to another
* service, so Next can't prefix it: it must include the basePath. */
loginReturnPath: appEnv === 'production' ? '/tools/auth-admin/applications' : '/applications',
} as const;
Two choices worth noting:
- The variable is
NEXT_PUBLIC_, so the same flag is visible to the build config and to browser code. Two separate variables would eventually disagree. - The default is production. If someone forgets to set it on the server, the app still gets its production prefix. Local development sets it explicitly. Defaulting to "development" would silently break production.
2. Fix the redirect: include the prefix where Next can't
The redirect bug was fixed by putting the full path in loginReturnPath. Once the string included the prefix, the identity provider sent users back to the right page.
The general rule: any URL that leaves Next.js (sent to another service, assigned to window.location, put in an email) must be built with the base path explicitly.
3. Prefix static file links
Links to files in public/ use the configured base path:
export function guideUrl(guide: Guide): string {
return `${config.basePath}/guides/${guide.file}`;
}
One helper means every download link is right, and nobody has to remember the rule at each call site.
4. Standalone output for Docker
output: 'standalone',
With standalone output, next build produces a minimal server with only the dependencies it needs. The Docker image is built in three stages (install dependencies, build, run): the final stage copies only the standalone folder, the static assets and public/, runs as a non-root user, and starts with node server.js. Small images, fast starts, no full node_modules in production.
One trap deserves its own line: NEXT_PUBLIC_ variables are baked into the JavaScript bundle at build time. Setting them when the container starts changes nothing in the browser code. So the API URL (and anything else the browser needs) is passed as a Docker build argument:
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
Changing it means rebuilding the image, not just restarting the container.
5. Play nicely on a shared host
- The container publishes a dedicated host port instead of assuming 3000, because other apps already live on the same machine.
- The reverse proxy must agree with the app about the prefix. With
basePath, the app expects requests to include/tools/auth-admin/, so the proxy should forward the path as is. Stripping the prefix in the proxy and settingbasePathis a classic way to get a redirect loop or 404s.
6. A checklist for sub-path deployments
What we now check whenever an app moves under a prefix:
-
basePathset from one environment flag, defaulting to production - the same flag readable in the browser (
NEXT_PUBLIC_) - every
returnTo/ callback URL sent to other services includes the prefix - every
window.locationassignment includes the prefix - every link to
public/files uses a helper that adds the prefix - the proxy forwards the sub-path as is
- test once in a production-like setup, behind the proxy, before release
Lessons learned
basePathonly prefixes what Next.js routes.<Link>and the router: yes. Strings you build yourself: no.- URLs that leave your app are your responsibility. Redirect targets, callbacks and emails need the full path.
- Read the same flag everywhere. One
NEXT_PUBLIC_variable for config and browser code avoids drift. - Default to the safe environment. If a variable goes missing on the server, production should still be correct.
- Centralize URL building. A helper for static files removes a whole category of 404s.
- Know who strips the prefix. Either the proxy or the app owns it, never both.
- Development hides path bugs. Test behind the real proxy before you ship.
FAQ
What does basePath do in Next.js?
It serves the whole app under a URL prefix (like /docs) and automatically adds that prefix to next/link, next/router/next/navigation and Next.js's own assets. Plain anchors, manual window.location changes and URLs sent elsewhere are not prefixed.
Why do links to files in the public folder break with basePath?
Because a plain <a href="/file.pdf"> isn't processed by Next.js. Build those URLs with the base path yourself, for example through a small helper that prepends it.
Why does my login redirect go to the wrong page after adding basePath?
The redirect URL (returnTo or callback) is a plain string sent to another service, so Next.js can't prefix it. Include the base path in that URL explicitly.
Should my reverse proxy strip the path prefix for a Next.js app with basePath?
No. With basePath, the app expects requests to include the prefix. Forward the path unchanged; strip it only if you don't use basePath at all.
Facing a similar problem?
I help teams fix performance, backend and reliability issues in production.
Let's talk