Description
When deploying under a subpath (e.g. NEXT_PUBLIC_BASE_PATH=/webmail), Next.js prefixes <Link> and next/image automatically — but many hand-written URLs in the codebase bypass this and hit the origin root instead of the deployment subpath. Result: broken requests in production whenever the app isn't mounted at /.
There's already an opt-in apiFetch() helper in lib/browser-navigation.ts, but several call sites forgot to use it, and it can't help with <img src> / <link href> / window.location.* anyway because the browser resolves those itself.
Affected files
Raw fetch() / XMLHttpRequest not using apiFetch:
app/(main)/[locale]/auth/callback/page.tsx:99 — SSO completion
lib/plugin-sandbox/bundle-signing.ts:29 — plugin signing pubkey
lib/webdav/client.ts:35,121 — WebDAV proxy fetch + XMLHttpRequest.open
<img src> with /api/... or config values containing /api/...:
components/ui/avatar.tsx:239 (sender favicon)
app/(main)/admin/layout.tsx:280,332,362,394 (admin logo ×4)
app/(main)/admin/login/page.tsx:51
app/(main)/[locale]/login/page.tsx:725,875
components/layout/navigation-rail.tsx:451
components/pwa-install-prompt.tsx:69,78
app/(main)/setup/page.tsx:1332 (wizard upload preview)
<img src> with hard-coded /branding/...:
components/email/email-viewer.tsx:3216
components/settings/about-data-settings.tsx:126,131
components/settings/files-settings.tsx:98,128
Next.js metadata.icons (renders as <link rel="icon">):
app/(main)/layout.tsx:42 — icons: { icon: faviconUrl }
window.location.* bypassing the router:
app/(main)/setup/page.tsx:262 — window.location.assign('/admin/login')
app/(main)/[locale]/pro/page.tsx:175 — window.location.replace("/")
Suggested fix
Add a single helper module and patch window.fetch / XMLHttpRequest once at boot via the root layout. Helper is idempotent, so wrapping values that are already prefixed or external (https://...) is safe — no DB migration needed.
// lib/api-prefix.ts (new file)
export const API_PREFIX = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
export function withApiPath(path: string): string {
if (!API_PREFIX || !path) return path;
if (path.charCodeAt(0) !== 47) return path; // not absolute
if (path.charCodeAt(1) === 47) return path; // protocol-relative
if (path === API_PREFIX || path.startsWith(API_PREFIX + '/')) return path;
return API_PREFIX + path;
}
export function getApiPrefixInstallScript(): string {
const P = JSON.stringify(API_PREFIX);
return `(function(){var P=${P};if(!P||window.__apiPrefixInstalled)return;window.__apiPrefixInstalled=true;
function fix(u){if(typeof u!=='string'||u.charCodeAt(0)!==47||u.charCodeAt(1)===47||u===P||u.indexOf(P+'/')===0)return u;return P+u;}
var of=window.fetch;if(of)window.fetch=function(i,init){if(typeof i==='string')i=fix(i);return of.call(this,i,init);};
var X=window.XMLHttpRequest;if(X&&X.prototype.open){var oo=X.prototype.open;X.prototype.open=function(m,u){var a=[].slice.call(arguments);a[1]=fix(u);return oo.apply(this,a);};}})();`;
}
Wire into both root layouts (app/(main)/layout.tsx and app/(sandbox)/layout.tsx) inside <head>:
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: getApiPrefixInstallScript() }}
/>
→ This single change fixes all three "raw fetch" sites above without any call-site edits.
At every JSX / window.location.* site from the lists above, wrap the URL:
<img src={withApiPath(logoUrl)} />
icons: { icon: withApiPath(faviconUrl) }
window.location.assign(withApiPath('/admin/login'));
Steps to Reproduce
- Set
NEXT_PUBLIC_BASE_PATH=/webmail in .env.local
npm run build and deploy behind a reverse proxy mounting the app at https://host/webmail/
- Open
https://host/webmail/de/login and check DevTools → Network
Expected Behavior
All same-origin requests, image sources, <link> hrefs, and window.location navigations are prefixed with /webmail. E.g. GET /webmail/api/admin/branding/loginLogoDarkUrl.png.
Actual Behavior
Multiple URLs hit the origin root without the prefix and return 404:
GET /api/admin/branding/loginLogoDarkUrl.png → 404
GET /api/admin/branding/faviconUrl.png → 404
GET /api/favicon?domain=… → 404
GET /api/plugin-signing-pubkey → 404 (signed plugins fail to verify)
GET /api/webdav → 404 (Files page broken)
<link rel="icon"> points to /branding/Bulwark_Favicon.svg without the prefix
- Setup wizard's "Finish" navigates to
/admin/login instead of /webmail/admin/login
- Leaving Pro mode on mobile redirects to
/ instead of /webmail/
Bulwark Version
1.7.0
Stalwart Mail Server Version
0.16.5
Browser
Chrome / Chromium
Operating System
Windows
Screenshots / Screen Recording
No response
Relevant Logs or Error Output
Additional Context
No response
Description
When deploying under a subpath (e.g.
NEXT_PUBLIC_BASE_PATH=/webmail), Next.js prefixes<Link>andnext/imageautomatically — but many hand-written URLs in the codebase bypass this and hit the origin root instead of the deployment subpath. Result: broken requests in production whenever the app isn't mounted at/.There's already an opt-in
apiFetch()helper inlib/browser-navigation.ts, but several call sites forgot to use it, and it can't help with<img src>/<link href>/window.location.*anyway because the browser resolves those itself.Affected files
Raw
fetch()/XMLHttpRequestnot usingapiFetch:app/(main)/[locale]/auth/callback/page.tsx:99— SSO completionlib/plugin-sandbox/bundle-signing.ts:29— plugin signing pubkeylib/webdav/client.ts:35,121— WebDAV proxyfetch+XMLHttpRequest.open<img src>with/api/...or config values containing/api/...:components/ui/avatar.tsx:239(sender favicon)app/(main)/admin/layout.tsx:280,332,362,394(admin logo ×4)app/(main)/admin/login/page.tsx:51app/(main)/[locale]/login/page.tsx:725,875components/layout/navigation-rail.tsx:451components/pwa-install-prompt.tsx:69,78app/(main)/setup/page.tsx:1332(wizard upload preview)<img src>with hard-coded/branding/...:components/email/email-viewer.tsx:3216components/settings/about-data-settings.tsx:126,131components/settings/files-settings.tsx:98,128Next.js
metadata.icons(renders as<link rel="icon">):app/(main)/layout.tsx:42—icons: { icon: faviconUrl }window.location.*bypassing the router:app/(main)/setup/page.tsx:262—window.location.assign('/admin/login')app/(main)/[locale]/pro/page.tsx:175—window.location.replace("/")Suggested fix
Add a single helper module and patch
window.fetch/XMLHttpRequestonce at boot via the root layout. Helper is idempotent, so wrapping values that are already prefixed or external (https://...) is safe — no DB migration needed.Wire into both root layouts (
app/(main)/layout.tsxandapp/(sandbox)/layout.tsx) inside<head>:→ This single change fixes all three "raw fetch" sites above without any call-site edits.
At every JSX /
window.location.*site from the lists above, wrap the URL:Steps to Reproduce
NEXT_PUBLIC_BASE_PATH=/webmailin.env.localnpm run buildand deploy behind a reverse proxy mounting the app athttps://host/webmail/https://host/webmail/de/loginand check DevTools → NetworkExpected Behavior
All same-origin requests, image sources,
<link>hrefs, andwindow.locationnavigations are prefixed with/webmail. E.g.GET /webmail/api/admin/branding/loginLogoDarkUrl.png.Actual Behavior
Multiple URLs hit the origin root without the prefix and return 404:
GET /api/admin/branding/loginLogoDarkUrl.png→ 404GET /api/admin/branding/faviconUrl.png→ 404GET /api/favicon?domain=…→ 404GET /api/plugin-signing-pubkey→ 404 (signed plugins fail to verify)GET /api/webdav→ 404 (Files page broken)<link rel="icon">points to/branding/Bulwark_Favicon.svgwithout the prefix/admin/logininstead of/webmail/admin/login/instead of/webmail/Bulwark Version
1.7.0
Stalwart Mail Server Version
0.16.5
Browser
Chrome / Chromium
Operating System
Windows
Screenshots / Screen Recording
No response
Relevant Logs or Error Output
Additional Context
No response