How to Generate Hidden Source Maps in Vite
build.sourcemap: 'hidden' is the only Vite configuration that produces external .map files without appending a //# sourceMappingURL= comment to your production bundles — meaning browsers cannot fetch the maps, but your observability platform can. This page covers the complete workflow: configuring Vite Build Settings for Accurate Stack Traces to emit hidden maps, uploading them to an error tracking service, and deleting them from the distribution directory before CDN sync, as part of the broader Source Map Generation & Stack Trace Debugging pipeline.
Symptom / Trigger
Setting build.sourcemap: true in a public Vite project causes Rollup to append a //# sourceMappingURL= comment to every generated JavaScript chunk. Any request to that URL returns the full .map file, which contains your original TypeScript, JSX, or Vue source in the sourcesContent array. The following exchange reveals the exposure:
# After deploying with build.sourcemap: true
curl -s https://cdn.example.com/assets/Index-BcD4xQ2k.js | tail -1
# Output:
# //# sourceMappingURL=Index-BcD4xQ2k.js.map
curl -I https://cdn.example.com/assets/Index-BcD4xQ2k.js.map
# HTTP/2 200
# content-type: application/json
# content-length: 847293
A 200 response on the .map URL means your proprietary source is publicly accessible. Chrome DevTools’ “Sources” panel will automatically parse it and reconstruct the original file tree, including comments, variable names, and any inline configuration constants that survived into the build. Automated scanners make this trivial: a crawler that fetches each .js asset, reads the trailing sourceMappingURL comment, and downloads the referenced .map can rebuild an entire SPA’s source in seconds. Because the map’s sourcesContent array embeds the verbatim original text of every module, no separate access to your repository is required — the bundle itself advertises where the source lives.
The equivalent failure when build.sourcemap is omitted or false is the opposite: error tracking dashboards show only minified frames with no line information:
TypeError: Cannot read properties of undefined
at n (Index-BcD4xQ2k.js:1:4821)
at r (Index-BcD4xQ2k.js:1:9033)
at HTMLDocument.<anonymous> (Index-BcD4xQ2k.js:1:11204)
Root Cause Explanation
Vite delegates source map generation to Rollup during vite build. The build.sourcemap value is passed directly as Rollup’s output.sourcemap option. When the value is true, Rollup calls:
// Internal Rollup behaviour (simplified) — shows why sourceMappingURL is appended
chunk.code += `\n//# sourceMappingURL=${mapFileName}`;
fs.writeFile(mapFileName, JSON.stringify(sourceMapObject));
The comment is written unconditionally for true. The string 'hidden' is a Rollup-specific mode that writes the .map file but skips appending the comment — the file exists on disk but nothing in the bundle points to it, so browsers have no reason to request it.
'inline' encodes the entire map as a base64 data URI on the same sourceMappingURL line, embedding it directly in the JS file rather than writing a separate .map file.
It is worth being precise about what “hidden” does and does not change. The .map file written by 'hidden' is byte-for-byte identical to the one written by true — same version, sources, names, mappings, and sourcesContent fields defined by the Source Map v3 format. The only difference is the absence of the discovery comment in the JS bundle. That is enough, because browsers and DevTools never request a map they cannot discover; there is no directory-listing mechanism that lets a client guess map URLs. The mapping data is therefore still complete and uploadable, which is exactly why hidden maps preserve full symbolication while removing the public attack surface. If you need to understand the structure of the file you are uploading — for example to validate the mappings VLQ payload before shipping — the Understanding Source Map v3 Specification and Formats reference breaks down each field.
A second consequence follows from this: because the JS bundle no longer references its map, you cannot rely on the browser to load it for ad-hoc debugging. The map only becomes useful again once it reaches a system that knows how to pair it with the bundle by file name and release tag — your error tracking platform. That dependency is what makes the upload-then-delete sequence in the next section non-optional rather than a nicety.
Step-by-Step Fix
1. Set build.sourcemap to ‘hidden’
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline', // 'hidden' suppresses sourceMappingURL
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
sourcemapFileNames: 'assets/[name]-[hash].js.map', // keep name pattern aligned with chunks
},
},
},
}));
Run vite build and verify the last line of a generated chunk:
tail -1 dist/assets/Index-BcD4xQ2k.js
# Should NOT contain sourceMappingURL
# Correct output ends with minified code, no comment
2. Add a postbuild npm script
{
"scripts": {
"build": "vite build",
"postbuild": "node scripts/upload-maps.mjs && find ./dist -name '*.map' -delete"
}
}
The postbuild lifecycle hook fires automatically after npm run build completes. The && operator ensures the delete step only runs if the upload exits cleanly (exit code 0). If the upload fails, maps remain on disk and the CI job fails — preventing accidental CDN exposure of unuploaded maps.
3. Write the Sentry upload script
#!/usr/bin/env bash
# scripts/upload-maps.sh — called from postbuild
set -euo pipefail
RELEASE="${npm_package_version:-$(git rev-parse --short HEAD)}"
DIST_SHA="$(git rev-parse --short HEAD)"
echo "Uploading source maps for release ${RELEASE}, dist ${DIST_SHA}"
npx sentry-cli sourcemaps upload \
--release "${RELEASE}" \
--dist "${DIST_SHA}" \
--url-prefix "~/assets" \
./dist/assets/
echo "Upload complete."
The --url-prefix "~/assets" flag tells Sentry that the map files in ./dist/assets/ correspond to JavaScript served from /assets/ on the CDN. Mismatching this prefix is the most common cause of symbolication failure after upload.
4. Datadog alternative
If you use Datadog RUM instead of Sentry:
#!/usr/bin/env bash
# scripts/upload-maps-datadog.sh
set -euo pipefail
RELEASE="${npm_package_version:-$(git rev-parse --short HEAD)}"
npx @datadog/datadog-ci sourcemaps upload ./dist/assets/ \
--service my-app \
--release-version "${RELEASE}" \
--minified-path-prefix "https://cdn.example.com/assets/"
echo "Datadog upload complete."
Datadog’s CLI uses a full URL prefix instead of ~/-style relative paths. Check your Datadog RUM configuration in DD_APPLICATION_ID to ensure the service name matches.
5. Call the script from postbuild
Update package.json to call the bash script rather than the inline find command, so you can version-control and test the upload logic independently:
{
"scripts": {
"build": "vite build",
"postbuild": "bash scripts/upload-maps.sh && find ./dist -name '*.map' -delete"
}
}
Verification
After running npm run build, confirm three things:
# 1. No sourceMappingURL in the bundle
grep -r "sourceMappingURL" dist/assets/ && echo "FAIL: maps referenced" || echo "PASS: no sourceMappingURL"
# 2. No .map files remain in dist after postbuild
find ./dist -name "*.map" | wc -l
# Expected: 0
# 3. CDN endpoint returns 404 for any .map path
curl -o /dev/null -s -w "%{http_code}" \
https://cdn.example.com/assets/Index-BcD4xQ2k.js.map
# Expected: 404
In your Sentry project, trigger a test error after deployment and verify the stack trace resolves to original source lines:
# sentry-cli verify upload (optional — requires sentry-cli ≥ 2.0)
npx sentry-cli sourcemaps explain \
--release "${npm_package_version}" \
"~/assets/Index-BcD4xQ2k.js" 1 4821
# Expected: shows original source file, line, and column
Edge Cases & Gotchas
- postbuild hook races with async upload — If the upload script spawns background processes without
wait, the shell may exit before upload completes, then&&treats it as success and deletes maps prematurely. Always useset -eand synchronous CLI commands without&. - Monorepo workspaces with multiple
distdirectories — Runningfind ./dist -name '*.map' -deletefrom the repo root only deletes maps for the current package. In a monorepo, scope the upload and delete scripts to each package’s owndistdirectory, or run them from the individual package directory in CI. - Multiple Vite output formats — If
rollupOptions.outputis an array (e.g., ESM + legacy), Rollup writes maps for each format into the samedist/assets/directory. Ensure your upload glob covers both, and verify the--url-prefixmatches the format served in production, not the other. - npm version differences —
postbuildlifecycle hooks are supported in npm ≥ 5. If your CI uses yarn or pnpm, equivalent hooks differ: yarn haspostbuildinscripts, pnpm requires explicit lifecycle hook configuration in.npmrcorpackage.jsonpnpm.overrides.
FAQ
Does build.sourcemap: 'hidden' affect the Vite dev server?
No. vite dev ignores all build.* options — it serves inline source maps via its own HMR middleware regardless of what build.sourcemap is set to. The setting only applies when you run vite build.
Can I serve hidden maps on a private CDN instead of deleting them? Yes, but it increases the attack surface. You must configure the CDN path with IP allowlisting, signed request tokens, or Basic Auth. Deleting after upload is simpler, eliminates the ongoing maintenance of CDN auth rules, and ensures no residual exposure if CDN config drifts. The Securing Hidden Source Maps from Public Access reference covers this approach in full.
Why does Sentry still show minified code after I uploaded maps with the correct release?
The three most common causes: (1) the --url-prefix does not match the actual URL path where the JS is served, (2) the release tag in Sentry.init() does not exactly match the --release value passed to the upload CLI, or (3) sourcemapFileNames used a different hash token than chunkFileNames, so the map file name does not match what Sentry expects. Run sentry-cli sourcemaps explain to identify which of these is failing.