Engineering Journal
Pdf Processor
Pdf Processor

The Vite + <base href> Trap That Silently Breaks Production Builds

2026-05-22

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">:

  1. Browser sees ../src/app.js
  2. Applies base: resolves relative to /tools/pdf-processor/
  3. ../src/app.js relative to /tools/pdf-processor/ = /src/app.js
  4. Requests /src/app.js — 404
With the inline import form <script type="module">import '../src/app.js';</script>:
  1. Rollup sees import '../src/app.js' during build
  2. Resolves it at build time relative to the HTML file's filesystem path
  3. Bundles editor/index.html + ../src/app.js correctly
  4. Outputs a rewritten bundle path in the final HTML
  5. Runtime <base href> never touches the rewritten bundle path
Vite dev mode rewrites bare specifiers and resolves module imports on the fly through its transform pipeline. The <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.

Read this post in the full Engineering Journal →