The Vite + <base href> Trap That Silently Breaks Production Builds
TLDR: <script src="../src/app.js"> inside an HTML with <base href="/tools/pdf-processor/"> resolves to /src/app.js at runtime, not /tools/pdf-processor/src/app.js. Vite builds fine locally. The browser 404s in production. Fix: use the inline import form <script type="module">import '../src/app.js';</script> so Rollup resolves paths at build time before the base href gets involved.
Repo: tools/pdf-processor
The Setup
The PDF processor is a multi-page Vite app. Three of its four entry points live in subdirectories:
tools/pdf-processor/
index.html ← root entry
editor/index.html ← subdirectory entry
visual-diff/index.html ← subdirectory entry
compare/index.html ← subdirectory entry
src/
app.js
styles.css
Each subdirectory HTML has a <base href="/tools/pdf-processor/"> tag so that relative links (CSS variables, asset imports, API calls) resolve from the tool root instead of the subdirectory.
The Bug
After adding visual-diff and compare views, commit 5792c70 changed the script tags in those HTMLs from:
<script type="module">import '../src/app.js';</script>
to:
<script type="module" src="../src/app.js"></script>
The reasoning looked correct. Both forms should import the same file. In Vite dev mode, both work. In the Cloudflare production deploy, the second form silently 404s. Nothing loads, no visible error unless you open DevTools.
Why It Breaks
The browser applies <base href> before resolving any relative URL in the document. This includes <script src>.
With <base href="/tools/pdf-processor/"> and <script src="../src/app.js">:
- Browser sees
../src/app.js - Applies base: resolves relative to
/tools/pdf-processor/ ../src/app.jsrelative to/tools/pdf-processor/=/src/app.js- Requests
/src/app.js— 404
<script type="module">import '../src/app.js';</script>:
- Rollup sees
import '../src/app.js'during build - Resolves it at build time relative to the HTML file's filesystem path
- Bundles
editor/index.html+../src/app.jscorrectly - Outputs a rewritten bundle path in the final HTML
- Runtime
<base href>never touches the rewritten bundle path
<base href> issue only surfaces when Rollup produces a static HTML output.
The Three-Layer Fix
The path regression was the last of three failures that killed the production deploy. Here are all three in order, because they are often found together.
Layer 1 — build.sh was not running the Vite build
build.sh was doing:
cp -r tools dist/tools
This copies raw source, including bare ES module specifiers like import $ from 'jquery'. Browsers cannot resolve bare specifiers without a bundler. Vite dev rewrites them. Cloudflare Pages does not.
Fix:
(cd tools/pdf-processor && npm ci --prefer-offline --no-audit && npm run build)
cp -r tools/pdf-processor/dist dist/tools/pdf-processor
Layer 2 — rollupOptions.input was missing three entries
vite.config.js only listed the editor:
rollupOptions: {
input: {
editor: 'editor/index.html'
}
}
Rollup silently skips entry points not listed in input. The other three HTMLs were never processed.
Fix:
rollupOptions: {
input: {
main: 'index.html',
editor: 'editor/index.html',
visualDiff: 'visual-diff/index.html',
compare: 'compare/index.html'
}
}
Layer 3 — stale committed dist was shadowing fresh builds
tools/pdf-processor/dist/ had been committed to the submodule (101 files). Cloudflare was serving the stale committed version whenever the build step was skipped or failed silently.
Fix: add dist/ to the submodule .gitignore, untrack it with git rm -r --cached dist. Build artifacts are CI-derivable and must never be committed.
The Rule
Never use <script src="relative/path.js"> inside an HTML file that has a <base href>. The browser resolves the src after applying base, not before.
Use the inline import form instead:
<!-- safe: Rollup resolves this at build time, before base href applies -->
<script type="module">import '../src/app.js';</script>
<!-- unsafe with <base href>: browser applies base first, path goes wrong --> <script type="module" src="../src/app.js"></script>
The same rule applies to <link rel="stylesheet" href="...">. If your HTML has <base href>, any relative URL in it is resolved relative to the base, not the file's location on disk.
Why Vite Dev Mode Hides This
Vite's dev server intercepts all requests and resolves imports through its own transform pipeline. It does not rely on the browser's HTML parser to resolve <script src>. The <base href> is never applied to module specifiers in dev mode because Vite's request interceptor handles resolution first.
This is the same category of bug as "works in create-react-app, breaks in production nginx." The dev toolchain abstracts away something the browser does natively, and the abstraction leaks exactly when you ship.
Tradeoffs
Inline import form is less readable. <script type="module">import '../src/app.js';</script> is more verbose than <script src="../src/app.js">. The clarity cost is worth avoiding the production footgun.
The stale dist problem recurs if the CI step is ever skipped. The gitignore fix is permanent, but any future engineer adding a new tool must know to add the Vite build step to build.sh. This is now documented in build.sh comments.
rollupOptions.input is not validated. Vite will not warn you if an HTML entry in input does not exist. It also will not warn you if an HTML exists but is not listed in input. Audit the input map whenever adding or removing entry HTMLs.