Configuring Webpack for Production Source Maps
Minified production bundles make error reports nearly unreadable: a stack trace pointing at main.a3f9c2.js:1:48291 tells you nothing about which component broke or why. The fix is generating source maps that let your error tracking service translate minified positions back into original file names, line numbers, and variable names — without ever exposing that source to end users. This page walks through the exact Webpack 5 configuration needed to produce secure, symbolication-ready source maps: the right devtool setting, when to reach for SourceMapDevToolPlugin instead, how to thread mappings through babel-loader, and how to lock .map files away from public access. These techniques are part of the broader discipline covered in Source Map Generation & Stack Trace Debugging. If you are evaluating Vite instead, see Vite Build Settings for Accurate Stack Traces for a parallel treatment. Common downstream problems — CDN path mismatches and Babel-induced mapping corruption — are covered in the child pages linked throughout.
After working through this guide you will be able to:
- Pick the correct
devtoolpreset for production and understand exactly what each one emits. - Replace the
devtoolshorthand withSourceMapDevToolPluginfor per-file inclusion control. - Configure
babel-loaderso the transpilation step preserves rather than destroys your mappings. - Set
output.sourceMapFilenameandoutput.publicPathso your error tracker can fetch maps reliably. - Block public access to
.mapfiles at the server layer while keeping them reachable for your error tracker.
Problem Framing & Symptom Identification
The problem surfaces in two ways. First, your error tracker receives a stack trace like at t (main.3a9f.js:1:82341) and cannot resolve it — either because no .map file was uploaded, or because the upload URL does not match the bundle’s expected path. Second, the opposite problem: .map files are public, so a curious user can curl https://yourapp.com/static/main.3a9f.js.map and read your entire unminified codebase, including environment variable names embedded by earlier build steps.
The root causes are almost always one of three misconfigurations:
- Wrong
devtoolvalue. Usingsource-mapin production appends//# sourceMappingURL=main.js.mapto every bundle, causing browsers to fetch the file. Usingeval-source-mapproduces no external file at all — only inline eval blocks — making upload-based symbolication impossible. - Broken mapping chain through Babel. When
babel-loaderruns withoutsourceMaps: true, it emits transformed code with no source map of its own. Webpack then tries to compose a final map from intermediate maps that do not exist, producing a map that points at the Babel output rather than your original.tsor.jsxfiles. - Mismatched
output.publicPath. Your error tracker fetches.mapfiles using the path embedded in the error event. If that path was constructed from apublicPaththat pointed at a staging CDN domain while the production build deployed to a different CDN, every symbolication request 404s. This specific failure mode has a dedicated fix guide at Fixing Incorrect Source Map Paths After CDN Deployment.
Prerequisites & Environment Setup
These instructions assume Webpack 5, Node.js 18 or later, and a project that already has a working development build. Install the packages you will need:
npm install --save-dev \
webpack \
webpack-cli \
babel-loader \
@babel/core \
@babel/preset-env \
source-map-explorer \
source-map
source-map-explorer is used in the verification step to visualise bundle composition. The source-map package (the npm package, not the browser API) provides a programmatic way to query map files from the command line during debugging.
Your baseline webpack.config.js should look like this before applying the changes in this guide:
// webpack.config.js (baseline — do not use as-is in production)
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
// devtool is not set — no source maps generated
};
Step-by-Step Implementation
1. Choose Your devtool Strategy
Webpack’s devtool option is a shorthand that selects a preset combination of source map format, location, and bundle annotation. The following table covers the values relevant to production:
devtool value |
External .map file |
sourcesContent in map |
//# sourceMappingURL in bundle |
Production use |
|---|---|---|---|---|
source-map |
Yes | Yes | Yes | No — exposes map URL |
hidden-source-map |
Yes | Yes | No | Yes — preferred |
nosources-source-map |
Yes | No | Yes | Compliance-only |
eval-source-map |
No (inline) | Yes | N/A | Development only |
cheap-module-source-map |
Yes | No | Yes | CI debugging only |
For most production deployments, hidden-source-map is the right choice. It writes a fully qualified .map file alongside each bundle but omits the //# sourceMappingURL= comment, so browsers never know the map exists and never request it. Your error tracker uploads the map out-of-band using the build artifact path.
nosources-source-map is appropriate when a legal or compliance requirement prohibits storing original source in any third-party system. The map file still contains the VLQ-encoded position mappings, so line numbers resolve correctly, but the sourcesContent array is empty — meaning you see the file name and line number in stack traces but not the actual code snippet.
Add the setting to your config:
// webpack.config.js
module.exports = {
mode: 'production',
devtool: 'hidden-source-map', // no sourceMappingURL comment in bundle output
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
sourceMapFilename: '[name].[contenthash].js.map', // explicit — avoids default naming collisions
},
};
2. Configure SourceMapDevToolPlugin (When devtool: false)
The devtool shorthand applies the same setting to every chunk. If you need finer control — for example, generating maps for your application code but not for vendor chunks, or appending a custom append comment for an internal symbolication service — switch to SourceMapDevToolPlugin and set devtool: false to prevent double-processing.
// webpack.config.js
const webpack = require('webpack');
const path = require('path');
module.exports = {
mode: 'production',
devtool: false, // REQUIRED when using SourceMapDevToolPlugin — must be false, not omitted
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
plugins: [
new webpack.SourceMapDevToolPlugin({
// Only generate maps for app chunks, skip vendor
test: /\.js$/,
exclude: /vendors/,
// Write maps to a separate directory, not alongside bundles
filename: 'sourcemaps/[name].[contenthash].js.map',
// No append → same behaviour as hidden-source-map (no URL comment)
append: false,
// Keep full source content for your error tracker
noSources: false,
// Useful for Sentry, Datadog, etc. — embed the release in the map
moduleFilenameTemplate: '[resource-path]',
}),
],
};
The key difference between devtool: 'hidden-source-map' and SourceMapDevToolPlugin with append: false is that the plugin lets you set filename to a path outside output.path, write maps to a dedicated directory, and exclude specific chunks. When append is false, no URL comment is added to bundles — equivalent to hidden-source-map. When you need the comment (for instance, to point internal tooling at an intranet URL), set append to a string: append: '//# sourceMappingURL=https://internal.corp/maps/[url]'.
Do not combine devtool with SourceMapDevToolPlugin targeting the same files. Webpack will process source maps twice, producing malformed output and significantly slower builds.
3. Thread Source Maps Through babel-loader
Babel transforms your source before Webpack sees it. If Babel produces its own intermediate map and Webpack does not consume that map, the final Webpack map points at the Babel output (already-transformed JavaScript) rather than your original TypeScript or JSX. Two options control this:
sourceMaps: true— tells Babel to emit an intermediate map alongside each transformed file.inputSourceMap: true— tellsbabel-loaderto pass that intermediate map to Webpack as the input map, so Webpack can compose the final map correctly.
Both must be set:
// webpack.config.js — module.rules section
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', { modules: false }],
'@babel/preset-typescript', // if using TypeScript
],
// Emit intermediate source map from Babel
sourceMaps: true,
// Pass that intermediate map to Webpack for composition
inputSourceMap: true,
// Prevent Babel from collapsing multiple statements onto one line
// — line collapse destroys column accuracy in the final map
compact: false,
},
},
},
],
},
The compact: false setting is easy to overlook. Babel’s default compact: 'auto' enables compaction above a size threshold. When compaction fires, many statements are merged onto a single line, and the resulting source map has a single entry pointing at that merged line — not at the individual original lines. Setting compact: false prevents this, at the cost of slightly larger intermediate output (which Webpack’s own minifier compresses anyway).
For a full analysis of why Babel breaks source maps and how to diagnose it, see Why Source Maps Break After Babel Transpilation.
4. Set output.publicPath and sourceMapFilename
The output.publicPath value determines the base URL that Webpack embeds in asset references — and, when using source-map or nosources-source-map, in the //# sourceMappingURL= comment. Even with hidden-source-map (no comment), publicPath affects the asset manifest your error tracker uses to fetch map files during symbolication.
// webpack.config.js
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
// Inject CDN_URL at CI build time; fall back to / for local builds
publicPath: process.env.CDN_URL || '/',
// Use a predictable pattern so your CI script can glob the right files for upload
sourceMapFilename: '[name].[contenthash].js.map',
},
In CI, set CDN_URL to the exact origin your users will hit — for example https://cdn.yourapp.com/assets/. This ensures that if an error event includes the full asset URL (as Sentry and similar services do), the symbolication service can reconstruct the expected map URL by appending .map and fetching from the same CDN origin (where you have uploaded the maps to a private bucket or restricted path).
When the CDN origin changes between deploys and old map files are not retained, old stack traces become unsymbolicated permanently. Version your map storage by content hash — the [contenthash] token in sourceMapFilename ensures that each unique build gets a unique map file name.
5. Restrict .map File Access at the Server Layer
Generating maps with hidden-source-map removes the in-bundle pointer, but the .map files still exist on disk and will be served by your web server if a user guesses the URL. You need an explicit server-layer rule to block public access.
For Nginx:
# Nginx — block all public access to source maps
location ~* \.map$ {
# Return 404 rather than 403 to avoid confirming that maps exist
return 404;
}
For Express (Node.js):
// Block .map file access before static middleware
app.use((req, res, next) => {
if (req.path.endsWith('.map')) {
return res.status(404).end();
}
next();
});
app.use('/static', express.static(path.join(__dirname, 'dist')));
For Caddy:
respond *.map 404
After blocking public access, upload maps to your error tracker during CI — either directly or via a separate, access-controlled storage bucket. Most error trackers (Sentry, Datadog, Rollbar) provide a CLI artifact upload command:
# Example: Sentry CLI upload after Webpack build
sentry-cli releases files "$RELEASE_VERSION" upload-sourcemaps ./dist \
--url-prefix "https://cdn.yourapp.com/assets/" \
--rewrite
The --url-prefix must match output.publicPath exactly. A trailing-slash mismatch is one of the most common causes of failed symbolication. For a broader treatment of server-layer security for map files, see Securing Hidden Source Maps from Public Access.
Production Telemetry Integration
Once maps are generated and uploaded, wire them into your error tracker by associating map files with a release identifier. The release identifier connects an error event (which carries the bundle URL and column offset) to the correct map file (which carries the position-to-source translation).
The general pattern across error trackers is:
- Generate a release identifier during CI. The safest choice is the full Git commit SHA:
git rev-parse HEAD. - Pass the identifier to Webpack via
DefinePluginso it is embedded in the bundle and reported with every error. - Create a release in your error tracker and upload the
.mapfiles against that release.
// webpack.config.js — DefinePlugin for release tracking
const webpack = require('webpack');
const { execSync } = require('child_process');
const RELEASE = process.env.RELEASE_VERSION
|| execSync('git rev-parse HEAD').toString().trim();
module.exports = {
// ... other config
plugins: [
new webpack.DefinePlugin({
'__RELEASE__': JSON.stringify(RELEASE),
}),
// ... other plugins
],
};
Then in your error initialisation code:
// src/monitoring.js
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: __RELEASE__, // injected by DefinePlugin at build time
});
This ties every client-side error to the exact build that produced it, so symbolication uses the correct map file even when multiple releases are deployed simultaneously (for example, during a rolling deploy or A/B test).
Verification & Testing
After building, verify the output before deploying.
Check that no sourceMappingURL comment appears in bundles (when using hidden-source-map):
grep -r "sourceMappingURL" dist/*.js && echo "FAIL: comment found" || echo "OK: no comment"
Verify map files exist and are non-empty:
find dist -name "*.map" -size +0c | wc -l
# Should equal the number of JS chunks
Inspect bundle composition with source-map-explorer:
npx source-map-explorer dist/main.*.js --html report.html
open report.html
This produces a treemap showing how each source file contributes to the bundle. If you see webpack:// paths pointing at Babel intermediate output rather than your original source files, the inputSourceMap: true option in babel-loader is missing or not taking effect.
Programmatically verify a mapping using the source-map npm library:
// scripts/verify-map.mjs
import { SourceMapConsumer } from 'source-map';
import { readFileSync } from 'fs';
const rawMap = readFileSync('./dist/main.abc123.js.map', 'utf8');
await SourceMapConsumer.with(JSON.parse(rawMap), null, (consumer) => {
// Test: does column 82341 on line 1 resolve to something readable?
const pos = consumer.originalPositionFor({ line: 1, column: 82341 });
console.log(pos);
// Expected: { source: './src/components/App.jsx', line: 47, column: 3, name: 'handleClick' }
// Bad: { source: null, line: null, column: null, name: null }
});
A null source in the output means the mapping is broken. Common causes: devtool and SourceMapDevToolPlugin were both active, compact: true collapsed lines, or inputSourceMap was not set in babel-loader.
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
| Stack traces resolve to Babel output, not original source | babel-loader missing inputSourceMap: true; Webpack has no intermediate map to compose from |
Add inputSourceMap: true and sourceMaps: true to babel-loader options |
.map files are publicly accessible despite hidden-source-map |
Web server serves all files in output.path without restriction |
Add server-layer block: location ~* \.map$ { return 404; } |
| Symbolication 404s after CDN deployment | output.publicPath baked into build does not match production CDN origin |
Inject CDN_URL env var at CI build time and rewrite the embedded map path to the production CDN origin |
devtool and SourceMapDevToolPlugin both active |
Double-processing produces malformed or duplicated maps | Set devtool: false when using SourceMapDevToolPlugin; never use both for the same chunks |
| Maps generated but not uploaded; no symbolication | CI script omits artifact upload step or uses wrong --url-prefix |
Add upload step to CI pipeline; verify --url-prefix matches output.publicPath exactly including trailing slash |
| Column numbers off by one after TypeScript compilation | @babel/preset-typescript strip-only mode does not emit source maps by default |
Add sourceMaps: true explicitly in Babel config when using TypeScript preset |
FAQ
Should I use hidden-source-map or nosources-source-map for production?
Use hidden-source-map if your error tracking service needs to display source code snippets alongside stack frames — this requires sourcesContent in the map. Use nosources-source-map if a legal or compliance policy prohibits storing original source code on a third-party system. With nosources-source-map you still get accurate file names and line numbers in stack traces, but no inline code snippets.
Why does SourceMapDevToolPlugin conflict with the devtool option?
Both mechanisms hook into the same Webpack source map generation pipeline. If both are active for the same chunk, Webpack runs the pipeline twice, producing a final map that references intermediate output rather than your original source. Setting devtool: false disables the shorthand pipeline entirely and leaves SourceMapDevToolPlugin as the sole controller. Never omit devtool: false when adding the plugin.
How do I verify source maps are working before deploying to production?
Run npx source-map-explorer dist/main.*.js and confirm the treemap shows your original source file paths, not webpack:// synthetic paths. Then run the programmatic check with SourceMapConsumer.originalPositionFor on a known column offset from a test error. If both checks pass, the maps are correctly chained from original source through Babel through Webpack.
Can I generate source maps for vendor chunks without including third-party source code in the map?
Yes. Use SourceMapDevToolPlugin (with devtool: false) and set noSources: true only for vendor chunks by using the test and exclude options to create two plugin instances — one with noSources: false for application code and one with noSources: true for vendor chunks. This keeps your proprietary source out of third-party map files while still enabling line-number resolution for library stack frames.
Related
- Source Map Generation & Stack Trace Debugging — parent overview covering source map formats, toolchain integration, and error tracker workflows.
- Vite Build Settings for Accurate Stack Traces — parallel guide for Vite-based projects:
build.sourcemapoptions and plugin configuration. - Securing Hidden Source Maps from Public Access — server, CDN, and storage-layer controls for keeping
.mapfiles private. - Fixing Incorrect Source Map Paths After CDN Deployment — targeted fix for
publicPathmismatches that cause symbolication 404s after moving assets to a CDN. - Why Source Maps Break After Babel Transpilation — deep dive into the Babel intermediate map chain and how
compact: trueand missinginputSourceMapdestroy mapping accuracy. - Choosing the Right Webpack devtool Setting — detailed comparison of all
devtoolpresets with build-time and map-quality benchmarks.