Vite Build Settings for Accurate Stack Traces
Vite’s Rollup-based production pipeline compresses and renames every module, making raw stack traces unreadable without source maps that remain accurate across code-splitting, minification, and CDN deployment. The full Source Map Generation & Stack Trace Debugging discipline covers symbolication end-to-end; this page focuses on the Vite-specific knobs that control what gets emitted, where it lands, and whether browsers can fetch it. Teams moving from webpack should compare the build.sourcemap semantics here against the devtool option documented in Configuring Webpack for Production Source Maps, and consider hardening delivery with the patterns in Securing Hidden Source Maps from Public Access.
Prerequisites: Vite ≥ 4.0, Node ≥ 18, a project using vite build (not the dev server). Familiarity with vite.config.ts and basic Rollup output concepts is assumed.
By working through this page you will be able to:
- Select the correct
build.sourcemapvalue (true,'inline', or'hidden') for each deployment environment - Configure Rollup output options to keep chunk file names and map file names in lock-step
- Tune esbuild’s minification flags to preserve column-level accuracy without sacrificing bundle size
- Wire a post-build upload/delete script so hidden maps reach your observability platform before CDN sync
- Validate every
.mapfile programmatically before deployment
Problem Framing & Symptom Identification
When a production error is captured by an observability SDK, the stack frame looks like:
TypeError: Cannot read properties of undefined (reading 'map')
at n (index-BcD4xQ2k.js:1:4821)
at r (index-BcD4xQ2k.js:1:9033)
The module name is a content-hashed chunk, the line is always 1 (everything is on one minified line), and the column number is meaningless without a corresponding .map file. This is the default output of vite build when build.sourcemap is not set — it defaults to false.
Three failure modes are common:
- Maps missing entirely —
build.sourcemapwas not set or isfalse. No.mapfiles exist. Observability dashboards show only minified frames. - Maps public —
build.sourcemap: trueor the legacysourcemap: truewas used. Vite appends a//# sourceMappingURL=index-BcD4xQ2k.js.mapcomment to every bundle. Any browser DevTools session or automated scanner can fetch and reconstruct the full source tree. - Maps exist but symbolication fails —
'hidden'is set correctly, butsourcemapFileNamesdoes not align with the upload script’s glob, orchunkFileNamesuses a different hash pattern, creating orphaned.mapfiles that the observability platform cannot match to their bundles.
Distinguishing these three is the first diagnostic step. Open one captured event in your observability dashboard and inspect a single frame. If the file is a hashed chunk and the SDK reports “no source map found,” you are in failure mode 1 or 3 — the difference is whether a .map file physically exists in your build output. Run ls dist/assets/*.map after a local production build: an empty result confirms mode 1, while a populated directory whose hashes do not match the deployed chunk names confirms mode 3. If instead a browser can open the .map URL directly and the response is a JSON body containing sourcesContent, you are in failure mode 2 and leaking source. Each mode has a distinct root cause and a distinct fix, so resist the urge to change multiple config keys at once — that obscures which knob actually resolved the problem.
A subtler variant of mode 3 occurs after dependency upgrades. When a vendor package bumps its version, the content hash of its chunk changes, but any maps you uploaded for the previous release still reference the old hash. The observability platform matches maps to bundles by the combination of release identifier and file name, so a stale release tag will appear to “work” for old events while silently failing for new ones. The manualChunks strategy in step 3 below mitigates this by giving vendor bundles stable, predictable names that change only when their contents genuinely change.
Prerequisites & Environment Setup
Install the toolchain and verify versions before changing any config:
node --version # need ≥ 18.0.0
npm --version # need ≥ 9.0.0
npx vite --version # need ≥ 4.0.0
Install the source-map library for local validation (not a build dependency, dev-only):
npm install --save-dev source-map
For Sentry upload integration, install the CLI:
npm install --save-dev @sentry/cli
For Datadog, install the datadog-ci tool:
npm install --save-dev @datadog/datadog-ci
Confirm your project structure has a vite.config.ts (or vite.config.js) at the root. All examples below use TypeScript config for full type support.
Step-by-Step Implementation
1. Understand the three sourcemap values
Vite passes build.sourcemap directly to Rollup’s sourcemap output option. The three non-false values have distinct effects:
| Value | External .map file? | sourceMappingURL in bundle? | Browser can fetch map? |
|---|---|---|---|
true |
Yes | Yes (relative path) | Yes — source exposed |
'inline' |
No | Yes (data URI, base64) | N/A — embedded in JS |
'hidden' |
Yes | No | No — safe for production |
true is safe in local development or staging behind auth. 'inline' inflates bundle size by roughly 2–3× and should be limited to debugging sessions. 'hidden' is the only value appropriate for public production deployments.
2. Configure build.sourcemap per environment
Use Vite’s defineConfig with a function signature to read the mode variable:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
// 'hidden' for production: maps generated but no sourceMappingURL comment
sourcemap: mode === 'production' ? 'hidden' : 'inline',
minify: 'esbuild',
},
}));
mode is 'production' when you run vite build and 'development' when you run vite dev. You can pass a custom mode via --mode staging to select intermediate behaviour.
3. Align chunkFileNames and sourcemapFileNames
Rollup names output chunks with chunkFileNames and names their maps with sourcemapFileNames. If these patterns diverge, the map file gets a hash that does not match the chunk name, and observability upload scripts cannot find the correct pairing:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
minify: 'esbuild',
rollupOptions: {
output: {
// chunk JS files go to assets/[name]-[hash].js
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
// map files mirror the chunk name exactly
sourcemapFileNames: 'assets/[name]-[hash].js.map',
},
},
},
}));
The [name] token resolves to the module’s base name (e.g., Index, vendor, LazyRoute). The [hash] token is the content hash Rollup computes from the chunk’s output. Because both chunkFileNames and sourcemapFileNames use the same [name]-[hash] template, the map for assets/Index-BcD4xQ2k.js will always be assets/Index-BcD4xQ2k.js.map.
4. Stabilize chunk names with manualChunks
Content-hashed names are good for cache-busting but bad for map stability when only one module in a large vendor bundle changes. Grouping dependencies into named manual chunks gives each group a deterministic [name] so the map pairing stays predictable across releases:
// vite.config.ts — group vendors into stable, named chunks
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
sourcemapFileNames: 'assets/[name]-[hash].js.map',
manualChunks(id) {
// Everything under node_modules/react* lands in a "react" chunk
if (id.includes('node_modules/react')) return 'react';
// All other third-party code lands in a single "vendor" chunk
if (id.includes('node_modules')) return 'vendor';
},
},
},
},
}));
With this in place, the output contains assets/react-<hash>.js and assets/vendor-<hash>.js. Their hashes change only when the underlying code changes, so the maps you upload for a release remain valid for every error event tagged with that release. This is especially important for code-split routes loaded via dynamic import(), where an unstable chunk name breaks symbolication for exactly the lazy-loaded paths users hit least often during testing — see Fixing Vite Source Maps with Dynamic Imports for the full diagnosis.
5. Control sourcesContent to balance payload vs. symbolication quality
By default, Rollup embeds the original source text inside each .map file under the sourcesContent array. This means an observability platform can show the exact line of code without fetching it from a repository:
// vite.config.ts — explicit sourcesContent control
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
sourcemapFileNames: 'assets/[name]-[hash].js.map',
// set to false only if map file size is a strict CI constraint
sourcemapExcludeSources: false,
},
},
},
}));
Setting sourcemapExcludeSources: true strips sourcesContent and reduces .map file size by 40–70%, but it forces your observability platform to fetch source files from a connected VCS integration. If that integration is broken or the commit is not yet pushed, symbolication silently degrades. Leave it false unless you have a hard artifact-size budget.
6. Tune esbuild minification for column accuracy
Vite’s default minifier is esbuild. esbuild produces source maps that are accurate at the statement level but can lose sub-expression column accuracy when minifySyntax: true rewrites AST nodes. For most observability purposes, line-level accuracy is sufficient, but if you need column-precise frame resolution (e.g., for React component boundary detection), tune the esbuild options:
// vite.config.ts — explicit esbuild minify options
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
minify: 'esbuild',
esbuild: {
// Renaming identifiers shrinks bundles but loses symbol names in maps
minifyIdentifiers: true,
// Collapsing whitespace has no impact on line/column accuracy
minifyWhitespace: true,
// Syntax transforms (e.g. arrow → function) can shift column offsets
minifySyntax: false, // set false if column accuracy is critical
},
},
}));
If you switch to terser for more aggressive dead-code elimination, install it explicitly (npm install --save-dev terser) and set minify: 'terser'. Terser produces slightly more accurate column mappings than esbuild’s AST transforms, at the cost of significantly slower build times (3–8× slower for large bundles).
// terser alternative — slower but higher column precision
import { defineConfig } from 'vite';
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
minify: 'terser',
terserOptions: {
sourceMap: true, // must be explicitly set when using terser
compress: { passes: 2 },
mangle: true,
},
},
}));
7. Add post-build upload and cleanup
Hidden maps are generated into dist/assets/ but must be uploaded to your observability platform and deleted before CDN sync. Add a postbuild script to package.json:
{
"scripts": {
"build": "vite build",
"postbuild": "node scripts/upload-sourcemaps.mjs && find ./dist -name '*.map' -delete"
}
}
The upload script itself:
// scripts/upload-sourcemaps.mjs
import { execSync } from 'child_process';
import { readdir } from 'fs/promises';
import path from 'path';
const dist = path.resolve('dist/assets');
const version = process.env.npm_package_version ?? execSync('git rev-parse --short HEAD').toString().trim();
// Upload all maps to Sentry, then the postbuild shell command deletes them
execSync(
`npx sentry-cli sourcemaps upload --release "${version}" --dist "${version}" "${dist}"`,
{ stdio: 'inherit' },
);
Production Telemetry Integration
After maps are uploaded, the observability SDK must tag each error event with a release identifier that matches the value you passed to sentry-cli sourcemaps upload --release. Set the Sentry SDK release in the same vite.config.ts via the define block to stamp a build-time constant:
// vite.config.ts — inject release constant at build time
import { defineConfig } from 'vite';
import { execSync } from 'child_process';
const release = process.env.npm_package_version
?? execSync('git rev-parse --short HEAD').toString().trim();
export default defineConfig(({ mode }) => ({
define: {
__SENTRY_RELEASE__: JSON.stringify(release), // accessible as a global in source
},
build: {
sourcemap: mode === 'production' ? 'hidden' : 'inline',
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
sourcemapFileNames: 'assets/[name]-[hash].js.map',
},
},
},
}));
In your app entry point:
// src/main.ts
import * as Sentry from '@sentry/browser';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
release: __SENTRY_RELEASE__, // injected by define above
});
The CI/CD Source Map Upload and Validation reference covers GitHub Actions workflows that automate this release tagging across multiple deployment environments. For teams using Mozilla’s source-map library for server-side symbolication without a third-party SDK, see Local Symbolication with Mozilla source-map Library.
Verification & Testing
Before deploying, verify every .map file resolves correctly. The source-map package provides a SourceMapConsumer that can spot-check coordinate resolution:
// scripts/validate-sourcemaps.mjs
import { SourceMapConsumer } from 'source-map';
import { readFile, readdir } from 'fs/promises';
import path from 'path';
const assetsDir = path.resolve('dist/assets');
const files = await readdir(assetsDir);
const maps = files.filter(f => f.endsWith('.map'));
let failed = 0;
for (const mapName of maps) {
const raw = await readFile(path.join(assetsDir, mapName), 'utf-8');
const consumer = await new SourceMapConsumer(raw);
// Check that line 1, column 0 of the generated file resolves to a real source
const pos = consumer.originalPositionFor({ line: 1, column: 0 });
if (!pos.source) {
console.error(`FAIL: ${mapName} — column 0 does not resolve to any source`);
failed++;
} else {
console.log(`OK: ${mapName} → ${pos.source}:${pos.line}`);
}
consumer.destroy();
}
if (failed > 0) process.exit(1); // fail the CI step
Add this to your package.json as a validate script and run it between build and postbuild:
{
"scripts": {
"build": "vite build && node scripts/validate-sourcemaps.mjs",
"postbuild": "node scripts/upload-sourcemaps.mjs && find ./dist -name '*.map' -delete"
}
}
After CDN deployment, confirm that map files return 404:
# replace with your actual CDN URL and hashed filename
curl -I https://cdn.example.com/assets/Index-BcD4xQ2k.js.map
# Expected: HTTP/2 404
And confirm the bundle contains no sourceMappingURL comment:
tail -3 dist/assets/Index-BcD4xQ2k.js | grep -c sourceMappingURL || echo "clean"
# Expected output: clean
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
Observability shows minified frames despite 'hidden' config |
Upload script path glob is too narrow — misses chunk maps in dist/assets/ |
Change glob to dist/assets/**/*.map and verify with ls dist/assets/*.map before upload |
.map files appear in CDN response (HTTP 200) |
postbuild delete ran before upload finished due to && short-circuit on upload error |
Separate upload and delete into two sequential scripts with explicit exit-code checks |
| Column numbers in stack frames are off by hundreds of characters | minifySyntax: true in esbuild rewrote AST, shifting column offsets |
Set minifySyntax: false or switch to terser with compress: { passes: 1 } |
sourcesContent is empty array in .map |
sourcemapExcludeSources: true was set explicitly or inherited from a Rollup plugin |
Set sourcemapExcludeSources: false in rollupOptions.output |
Dynamic-import chunks have .map files but Sentry cannot match them |
chunkFileNames and sourcemapFileNames use different hash seeds |
Ensure both use [name]-[hash] with no extra tokens between them |
terser produces maps with <anonymous> sources |
Arrow functions in vendor code were not preserved with keep_fnames |
Add terserOptions: { mangle: { keep_fnames: true } } |
FAQ
When should I use sourcemap: true versus 'hidden'?
Use true only in staging environments where the CDN or object storage bucket is behind authentication and direct bucket listing is disabled. Use 'hidden' for any public-facing deployment — it is the only value that prevents browsers from fetching the map file, regardless of CDN configuration. Never use true in production.
Does build.sourcemap affect the Vite dev server?
No. build.sourcemap is a build-phase option. During vite dev, the dev server always serves inline source maps via its own middleware, regardless of what build.sourcemap is set to. The setting only takes effect during vite build.
Can I use different sourcemap settings for different output formats in the same build?
Yes. Set rollupOptions.output to an array of output descriptors, each with its own sourcemap key. This is useful when generating both ESM and CommonJS bundles with different map strategies. The sourcemapFileNames in each descriptor applies only to that output.
How do I exclude vendor chunks from sourcemap generation to reduce artifact size?
Use a custom Rollup plugin that hooks into generateBundle and deletes map references for chunks whose facadeModuleId matches a vendor path pattern. This is an advanced approach — simpler is to set sourcemapExcludeSources: true only for vendor-heavy entry points using manualChunks.