Rollup & esbuild Source Map Configuration
When you build a library or an application directly with Rollup or esbuild — without the Vite or Webpack wrapper that hides their options behind a friendlier surface — you are responsible for every knob that decides whether a production stack trace resolves back to real source. Both bundlers emit correct maps by default in the simplest case, but the moment you add minification, custom output directories, code splitting, or a CDN path prefix, the raw mappings drift: sources point at nonexistent paths, columns land in the wrong expression, or the map silently ships to the public alongside your bundle. This guide is part of Source Map Generation & Stack Trace Debugging and sits alongside the framework-specific treatments in Vite Build Settings for Accurate Stack Traces and Configuring Webpack for Production Source Maps; here we work one layer lower, at the bundler API itself. You need Node 18+, a project that already produces a working unminified build with either tool, and a rough understanding of the source map v3 fields (sources, sourcesContent, sourceRoot, mappings).
After working through this guide you will be able to:
- Choose the right
sourcemapvalue in Rollup (true,'inline','hidden') and esbuild (linked,external,inline,both). - Rewrite the
sourcesarray withsourcemapPathTransformandsourceRootso paths resolve on your error tracker. - Emit hidden maps that carry no
sourceMappingURLcomment yet remain uploadable out-of-band. - Chain minification through
@rollup/plugin-terser(or esbuild’sminify) without corrupting column accuracy. - Validate every map programmatically before it reaches a CDN.
Problem Framing & Symptom Identification
A production error captured from a Rollup- or esbuild-built bundle usually arrives looking like this in your dashboard:
TypeError: cannot read properties of null (reading 'id')
at o (bundle.min.js:1:24817)
at Array.forEach (<anonymous>)
at c (bundle.min.js:1:24560)
Line 1 because minification collapsed everything onto one line, and a column offset that means nothing until a .map file translates it. The failure almost always falls into one of three buckets, and the fix depends entirely on which one you are in.
- No map at all. You set
output.sourcemapin Rollup, orsourcemapin esbuild, but only for one output and not the minified one — or you left it off the production config entirely. The.jsfile exists; no.js.mapsits next to it. Your error tracker reports “source map not found” for every frame. - Map exists but
sourcespaths are wrong. This is the Rollup/esbuild-specific trap. The map’ssourcesarray contains entries like../../src/app.tsor an absolute path like/home/runner/work/project/src/app.tsbaked in during CI. The error tracker looks for~/src/app.tsor a URL-prefixed path and never matches, so it reports the map as present but unresolvable. - Map is public.
sourcemap: true(Rollup) orsourcemap/'linked'(esbuild) appended a//# sourceMappingURL=bundle.min.js.mapcomment, the CDN happily serves the.map, and anyone can reconstruct your unminified source in DevTools.
The diagnostic move is the same in every case: run one local production build and inspect the artifacts directly. ls dist/*.map tells you whether a map physically exists (rules mode 1 in or out). tail -c 200 dist/bundle.min.js reveals whether a sourceMappingURL comment is present (mode 3). And node -e "console.log(require('./dist/bundle.min.js.map').sources)" prints the exact sources array so you can compare it against what your error tracker expects (mode 2). Change one variable at a time — the three modes have three unrelated root causes, and altering several config keys at once obscures which one actually mattered.
Mode 2 deserves extra attention because it is the one that looks like success right up until it fails. The map is present, the upload command reports that it accepted the file, and the release shows a healthy artifact count in your tracker’s dashboard — yet every incoming frame stays minified. The reason is that a source map is only ever matched to a source by string equality on the path, and Rollup and esbuild each compute that string from a different base directory than the one your tracker assumes. A local build on macOS, a container build in CI, and a monorepo build run from a nested package directory can all produce three different sources arrays for byte-identical source files. That is why the fixes in step 3 are not optional polish; on any non-trivial pipeline they are the difference between a map that resolves and one that quietly does nothing.
Prerequisites & Environment Setup
These instructions target Rollup 4, esbuild 0.20 or later, and Node.js 18+. Install the tools and the small set of dependencies used in the examples:
# core bundlers
npm install --save-dev rollup@^4 esbuild@^0.20
# minification for Rollup (esbuild has minify built in)
npm install --save-dev @rollup/plugin-terser terser
# module resolution + TypeScript for Rollup
npm install --save-dev @rollup/plugin-node-resolve @rollup/plugin-typescript typescript
# the Mozilla source-map library for local validation (dev only)
npm install --save-dev source-map
Confirm the versions before you touch any config, because the sourcemap semantics below changed subtly between Rollup 3 and 4 and between early and current esbuild:
npx rollup --version # expect rollup v4.x
npx esbuild --version # expect 0.20.x or newer
node --version # expect v18.x or newer
The examples assume a project whose entry point is src/index.ts and whose production output lands in dist/. If your source lives elsewhere, adjust the input / entryPoints and dir / outdir values accordingly — the source map behaviour is identical regardless of layout.
Step-by-Step Implementation
1. Set the Rollup sourcemap mode explicitly
Rollup exposes the source map decision as output.sourcemap, and unlike Webpack’s dozen devtool presets it has exactly three meaningful values plus false. The value is per-output, so if you emit both ESM and CommonJS builds you set it once per output descriptor.
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import { nodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'es',
entryFileNames: '[name].[hash].js',
// 'hidden' = generate the external .map but DO NOT append the
// //# sourceMappingURL comment. This is the only production-safe value.
sourcemap: 'hidden',
},
plugins: [nodeResolve(), typescript()],
};
The three non-false values behave as follows: true writes an external bundle.js.map and appends the sourceMappingURL comment (safe only behind auth); 'inline' base64-encodes the entire map into the bundle as a data URI, inflating the file two to three times (debugging only); 'hidden' writes the external map with no comment, which is what production wants. Setting it to 'hidden' means browsers never learn the map exists, so they never fetch it, yet the file is right there in dist/ for your upload step to grab.
2. Choose the matching esbuild sourcemap value
esbuild uses the same conceptual choices but different value names, and it has one extra mode — 'both' — that has no Rollup equivalent. Drive it from a build script rather than the CLI so you can compute paths and releases in JavaScript.
// build.mjs — esbuild production build
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
minify: true,
// 'external' writes bundle.js.map with NO sourceMappingURL comment,
// the esbuild equivalent of Rollup's 'hidden'.
sourcemap: 'external',
// keep the original code inside the map so the error tracker shows snippets
sourcesContent: true,
});
The esbuild values map onto Rollup like this: 'linked' (or sourcemap: true) writes an external map plus the comment (Rollup true); 'inline' embeds a data URI (Rollup 'inline'); 'external' writes the map with no comment (Rollup 'hidden'); and 'both' writes an external map and inlines it — never use 'both' in production because it ships the full source to the browser inside the bundle even though an external file also exists. For public deployments the only correct value is 'external'.
3. Fix the sources paths with sourceRoot and path transforms
This is the step teams skip, and it is the single most common reason a correctly generated map still fails to symbolicate. Both bundlers write the sources array as paths relative to the map file’s own location, which frequently produces entries like ../src/index.ts or, when built in CI, an absolute path leaking the runner’s home directory. Your error tracker matches frames against a normalized path (often prefixed with ~/ or a URL origin), so the raw relative path never lines up.
In Rollup, rewrite each entry with output.sourcemapPathTransform, and optionally set a sourceRoot that the tracker will prepend:
// rollup.config.js — normalize every source path
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'es',
sourcemap: 'hidden',
// Prepended (conceptually) to every entry in `sources` by consumers.
sourceRoot: 'app:///',
// Runs once per source path; return the string written into `sources`.
sourcemapPathTransform: (relativePath) => {
// Strip any leading ../ segments and CI absolute prefixes so every
// path becomes a stable, repo-relative "src/..." string.
const idx = relativePath.replace(/\\/g, '/').indexOf('src/');
return idx >= 0 ? relativePath.slice(idx) : relativePath;
},
},
plugins: [/* ... */],
};
esbuild has no sourcemapPathTransform hook, but it exposes sourceRoot directly and computes sources relative to outbase. Set outbase to your project root so the paths come out as clean src/... strings, then apply sourceRoot to align with the tracker:
// build.mjs — control esbuild source paths
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external',
// outbase decides what the sources paths are relative to.
outbase: '.',
// sourceRoot is written verbatim into the map's sourceRoot field.
sourceRoot: 'app:///',
});
Whatever prefix you pick for sourceRoot, use the same string when configuring your error tracker’s path rewriting, or the resolved path will be off by exactly that prefix.
4. Chain minification through Terser without breaking columns
Rollup does not minify on its own; you add @rollup/plugin-terser. The critical detail is that Terser must be given the incoming map so it can compose its own transformations on top, otherwise the final map describes the pre-minified bundle and every column is wrong. When you enable output.sourcemap, Rollup passes the intermediate map to the plugin automatically — but only if the plugin runs in the output plugin slot, not the input plugins array.
// rollup.config.js — minify while preserving the map chain
import terser from '@rollup/plugin-terser';
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'es',
sourcemap: 'hidden',
// Output-level plugins receive the composed map from Rollup.
plugins: [
terser({
// sourceMap: true is implied when Rollup sourcemap is on, but be explicit.
sourceMap: true,
// Keep function names so stack frames show `handleClick`, not `t`.
keep_fnames: true,
compress: { passes: 2 },
}),
],
},
plugins: [/* input plugins: typescript, node-resolve */],
};
Placing terser() in the top-level plugins array instead of output.plugins is a subtle bug: it runs before Rollup builds the source map, so Rollup then maps the already-minified code back to itself and you get a map whose sources is the minified bundle. Always put minification in output.plugins.
For esbuild the situation is simpler because minification and map generation happen in one pass, so the chain can never break — but the AST-level minifySyntax transform can still shift columns. If you need column-precise frames, disable syntax minification and keep only whitespace and identifier minification:
// build.mjs — column-safe esbuild minification
import { build } from 'esbuild';
await build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: 'external',
minifyWhitespace: true, // safe: never shifts column mapping
minifyIdentifiers: true, // renames locals; names come from the map
minifySyntax: false, // AST rewrites can move columns — keep off if precision matters
});
5. Emit predictable file names for the upload step
Your CI upload glob has to find the maps, and the maps have to pair with their bundles by name. In Rollup, sourcemapFileNames mirrors entryFileNames and chunkFileNames; keep the templates identical apart from the .map suffix so index.a1b2c3.js always produces index.a1b2c3.js.map.
// rollup.config.js — lock chunk and map names together
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'es',
sourcemap: 'hidden',
entryFileNames: '[name].[hash].js',
chunkFileNames: '[name].[hash].js',
// Same [name].[hash] seed as the chunks, with .map appended.
sourcemapFileNames: '[name].[hash].js.map',
},
plugins: [/* ... */],
};
esbuild names maps automatically as <chunk>.map, so with content hashing enabled through entryNames the pairing is guaranteed as long as you do not rename files in a later step:
// build.mjs — hashed, self-consistent names
await build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external',
// [hash] makes bundles cacheable; the .map inherits the same base name.
entryNames: '[name].[hash]',
chunkNames: 'chunks/[name].[hash]',
});
Production Telemetry Integration
Generating and naming the maps is half the job; the other half is uploading them against a release identifier so the error tracker can pair an event (which carries the bundle name and column) with the correct map (which carries the translation). The release identifier must be stamped into the bundle at build time and passed to the upload command unchanged.
In Rollup, inject the release with @rollup/plugin-replace or a small intro/define-style substitution, then upload after the build:
// rollup.config.js — stamp a release constant into the bundle
import replace from '@rollup/plugin-replace';
import { execSync } from 'node:child_process';
const RELEASE = process.env.RELEASE_VERSION
|| execSync('git rev-parse --short HEAD').toString().trim();
export default {
input: 'src/index.ts',
output: { dir: 'dist', format: 'es', sourcemap: 'hidden' },
plugins: [
replace({
preventAssignment: true,
// Replaces the literal __RELEASE__ token in source with the git SHA.
__RELEASE__: JSON.stringify(RELEASE),
}),
],
};
esbuild has a first-class define for exactly this, so no plugin is needed:
// build.mjs — define the release at build time
import { build } from 'esbuild';
import { execSync } from 'node:child_process';
const release = process.env.RELEASE_VERSION
|| execSync('git rev-parse --short HEAD').toString().trim();
await build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
minify: true,
sourcemap: 'external',
define: { __RELEASE__: JSON.stringify(release) },
});
Reference __RELEASE__ when you initialise your tracker so every event reports the build that produced it:
// src/monitoring.js
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: __RELEASE__, // replaced by the bundler at build time
});
Then upload the hidden maps out-of-band and delete them before the CDN sync, so the .map files never leave your build machine as public artifacts. The vertical flow below is the exact order these steps must run in; reversing upload and delete leaves either public maps or an empty release.
A robust post-build script separates upload from delete with explicit exit-code handling so a failed upload never triggers a delete:
// scripts/release-maps.mjs
import { execSync } from 'node:child_process';
import { rmSync, readdirSync } from 'node:fs';
import path from 'node:path';
const dist = path.resolve('dist');
const release = process.env.RELEASE_VERSION
|| execSync('git rev-parse --short HEAD').toString().trim();
// Step 1: upload. Throws (non-zero exit) on any failure, aborting the script.
execSync(
`npx sentry-cli sourcemaps upload --release "${release}" "${dist}"`,
{ stdio: 'inherit' },
);
// Step 2: only reached if upload succeeded — now strip maps from the deploy set.
for (const f of readdirSync(dist)) {
if (f.endsWith('.map')) rmSync(path.join(dist, f));
}
console.log(`Uploaded and cleaned maps for release ${release}`);
The broader automation of this across environments — GitHub Actions matrices, per-environment release tags, and retry logic — is covered in CI/CD Source Map Upload and Validation. If you prefer to symbolicate on your own server rather than through a hosted tracker, see Local Symbolication with Mozilla source-map Library.
Verification & Testing
Never deploy a build whose maps you have not verified. Three checks catch the overwhelming majority of failures.
Confirm no sourceMappingURL comment shipped (proves the map is hidden/external, not linked):
grep -l "sourceMappingURL" dist/*.js && echo "FAIL: comment present" || echo "OK: hidden"
Confirm the maps exist and are non-empty, and that their count matches the JS chunk count:
find dist -name "*.map" -size +0c | wc -l # should equal the number of JS chunks
Programmatically resolve a known position with the Mozilla source-map library. This is the definitive test: it proves the mappings and sources are internally consistent and that a real column resolves to a real source line.
// scripts/validate-maps.mjs
import { SourceMapConsumer } from 'source-map';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
const dist = path.resolve('dist');
let failed = 0;
for (const name of readdirSync(dist).filter((f) => f.endsWith('.map'))) {
const raw = readFileSync(path.join(dist, name), 'utf8');
const consumer = await new SourceMapConsumer(raw);
// Resolve line 1, column 0 of the generated file back to original source.
const pos = consumer.originalPositionFor({ line: 1, column: 0 });
if (!pos.source) {
console.error(`FAIL ${name}: column 0 resolves to nothing`);
failed++;
} else {
console.log(`OK ${name} -> ${pos.source}:${pos.line}`);
}
consumer.destroy();
}
if (failed > 0) process.exit(1); // fail the CI job
A null source means the map is broken — usually because Terser ran in the input plugins array (breaking the chain), or because sourcemapPathTransform returned an empty string. Run this script between build and the upload step so a bad map fails the pipeline instead of the deploy.
Finally, after the CDN sync, confirm from the outside that the map is not reachable:
curl -s -o /dev/null -w "%{http_code}\n" https://cdn.example.com/index.a1b2c3.js.map
# Expected: 404
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
| Map generated but every frame stays minified | sourcemap: 'both'/'inline' used, or the map’s sources never matches the tracker’s path convention |
Switch to Rollup 'hidden' / esbuild 'external' and normalize paths with sourcemapPathTransform or sourceRoot |
| Columns off by hundreds of characters | Terser ran in Rollup’s input plugins array, so the map describes pre-minified code |
Move terser() into output.plugins so it receives Rollup’s composed map |
sources shows /home/runner/... absolute CI paths |
esbuild outbase left at default, or Rollup path transform not applied |
Set outbase: '.' (esbuild) or add sourcemapPathTransform (Rollup) to emit repo-relative paths |
| esbuild frames land in the wrong expression | minifySyntax: true rewrote the AST and shifted columns |
Set minifySyntax: false, keeping minifyWhitespace and minifyIdentifiers |
.map reachable on the CDN (HTTP 200) |
Delete step ran via && after a failed upload short-circuit, or was never added |
Split upload and delete into sequential steps with explicit exit-code checks |
| Map uploaded but tracker reports “no map for release” | Bundle release constant differs from the --release value passed to upload |
Derive both from one source (git SHA) and stamp it via define/replace and the upload flag |
FAQ
What is the difference between esbuild’s 'external' and 'both' sourcemap values?
'external' writes a standalone bundle.js.map and does not touch the bundle, so the map exists only as a separate file you upload out-of-band — this is the production-safe choice. 'both' writes that external file and inlines the same map into the bundle as a base64 data URI. Because 'both' embeds the full source in the shipped JavaScript, anyone who downloads the bundle can reconstruct your source even though you also blocked the external .map, which defeats the entire purpose of hiding it. Use 'external' for anything public.
Why does my Rollup map point at the minified bundle instead of my TypeScript source?
Almost always because @rollup/plugin-terser is in the top-level plugins array instead of output.plugins. Input plugins run before Rollup composes the source map, so Terser minifies the code first and Rollup then maps the minified output back to itself. Move the plugin into output.plugins, where Rollup hands it the intermediate map to transform, and the final map will chain all the way back to your original source.
Do I need sourceRoot if I already use sourcemapPathTransform?
They solve related but distinct problems. sourcemapPathTransform (Rollup only) rewrites the actual strings inside the sources array, which is how you strip ../ segments or absolute CI prefixes. sourceRoot is a single prefix written once into the map’s sourceRoot field that consumers conceptually prepend to every source. You can use either alone, but they are most reliable together: transform the paths into clean repo-relative strings, then set a sourceRoot that matches whatever prefix your error tracker expects. In esbuild, which has no path-transform hook, outbase plus sourceRoot fills the same role.
Is it safe to keep sourcesContent in a hidden production map?
Yes, provided the map is genuinely hidden and never served publicly. sourcesContent embeds your original source text so the error tracker can show the exact failing line without a repository integration. Since a hidden/external map is uploaded to the tracker and deleted before the CDN sync, that source never reaches end users. Only strip sourcesContent (Rollup output.sourcemapExcludeSources: true, esbuild sourcesContent: false) when you have a hard artifact-size limit or a compliance rule against storing source in the tracker — you then trade code snippets for smaller maps while keeping accurate file names and line numbers.
Related
- Source Map Generation & Stack Trace Debugging — parent overview of source map formats, toolchains, and symbolication workflows.
- Vite Build Settings for Accurate Stack Traces — the same Rollup and esbuild internals as configured through Vite’s higher-level API.
- Configuring Webpack for Production Source Maps — the
devtoolequivalent of the modes described here for Webpack projects. - Securing Hidden Source Maps from Public Access — server, CDN, and storage controls for keeping
.mapfiles private after they are generated. - CI/CD Source Map Upload and Validation — automating the upload, validation, and cleanup steps across deployment environments.