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.

Vite hidden source map post-build workflow Sequential flow showing five stages: vite build produces bundle.js and bundle.js.map; the postbuild hook fires; the upload script sends .map files to an observability platform (Sentry or Datadog); the delete step removes .map files from dist; CDN sync copies only bundle.js with no .map alongside it. vite build bundle.js bundle.js.map postbuild npm hook fires Upload .map Sentry / Datadog Delete .map from dist/ CDN sync bundle.js only no .map ✓

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 use set -e and synchronous CLI commands without &.
  • Monorepo workspaces with multiple dist directories — Running find ./dist -name '*.map' -delete from the repo root only deletes maps for the current package. In a monorepo, scope the upload and delete scripts to each package’s own dist directory, or run them from the individual package directory in CI.
  • Multiple Vite output formats — If rollupOptions.output is an array (e.g., ESM + legacy), Rollup writes maps for each format into the same dist/assets/ directory. Ensure your upload glob covers both, and verify the --url-prefix matches the format served in production, not the other.
  • npm version differencespostbuild lifecycle hooks are supported in npm ≥ 5. If your CI uses yarn or pnpm, equivalent hooks differ: yarn has postbuild in scripts, pnpm requires explicit lifecycle hook configuration in .npmrc or package.json pnpm.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.