Guide · Errors

Readable stack traces: upload your source maps

To turn a minified stack trace back into your own files and lines, build with source maps on, upload the .map files to your error tracker after each build, and remove them from what you deploy. It takes one line in your build script.

8 min read

The otter calmly searching with a magnifying glass

What a source map is

  • Maps turn a.js:1:4821 into your line

    A source map records which original file, line and column every spot in the bundle came from.
  • One line after every build

    Build with maps on, run npx bugotter sourcemaps with the same release as your script tag.
  • Upload them, don’t ship them

    Maps are your source code. --delete removes them before deploy; a .map URL should return 404.

Your production JavaScript isn’t the code you wrote. The bundler joins your files, renames variables to single letters, strips whitespace and often puts the whole thing on one line. That’s good for speed and bad for debugging.

A source map is a JSON file that records how to get back. For every position in the bundled file, it says which original file, line and column it came from, and often the original name of the function or variable.

The format is standardized (ECMA-426, based on the long-used “version 3” format). The fields that matter:

  • sources: the original file paths, like webpack://my-app/./src/cart.ts.
  • mappings: a compact encoding of “this spot in the bundle is that spot in that source”.
  • names: original identifiers.
  • sourcesContent (optional): the original code itself, so a tool can show the line without access to your repo.

Bundles point to their map with a comment on the last line, //# sourceMappingURL=main.js.map. Browser DevTools follow it. Error trackers don’t need to: you upload the map to them directly.

  1. BuildThe bundler writes page-3f2a.js and its .map.
  2. Upload with a releaseThe CLI sends each map, tagged --release, then deletes it.
  3. Error arrives minifiedThe browser reports n at page.js:1:4821.
  4. Mapped to your lineBugOtter applies the map: summary.tsx:57, with the code.

Why minified traces are useless

Here’s a real-shaped production error without maps:

what the browser reports
TypeError: Cannot read properties of undefined (reading 'price')
    at n (https://yourapp.com/_next/static/chunks/app/checkout/page-3f2a1b9c0d1e2f3a.js:1:4821)
    at o (https://yourapp.com/_next/static/chunks/4bd1b696-9a8f7e6d5c4b3a21.js:1:12034)
    at https://yourapp.com/_next/static/chunks/4bd1b696-9a8f7e6d5c4b3a21.js:1:98211

Line 1, column 4,821, in a function called n. That tells you nothing you can search for. With the map applied, the same error reads:

the same error, mapped
TypeError: Cannot read properties of undefined (reading 'price')
    at OrderSummary (src/checkout/summary.tsx:57:31)
        const total = plan.price * seats;
    at renderWithHooks (node_modules/react-dom/…)

Now it’s obvious: plan is undefined at line 57.

This matters even more if a coding agent does the fixing. With a mapped stack, it opens one file at one line. Without it, it greps for minified names and guesses. (See letting Claude Code fix production errors.)

Maps also make grouping better. Errors are grouped by type and the top frames of your own code; when those frames are real file names instead of shifting chunk hashes, the same bug stays one issue across deploys.

A BugOtter error page for a ChunkLoadError on /pricing: summary, Fix with agent, events, users, first seen in v1.8.1, a 14-day chart, and Where your code broke showing PlanToggle in app/pricing/plans.tsx:12:5 with a SOURCE MAP badge
  1. First seen in v1.8.1: the release ties the error to the right map.
  2. Where your code broke: your component, file and line, with the line of code.
  3. SOURCE MAP means this frame was mapped from an uploaded map.
With maps applied, the error page shows your file and line, not a chunk hash.

Upload after every build

The pattern is the same for every tracker: build with maps, upload them tagged with a release, delete them from the output, deploy. With BugOtter’s CLI (Node 18+, no dependencies):

  1. Get the keys

    Run npx bugotter init once in the project (it writes BUGOTTER_PROJECT and BUGOTTER_KEY to .env.local), or copy both from the app’s page. In CI, set both as environment variables.

    BUGOTTER_KEY is the secret upload key, not the public DSN key in your bundle; uploads with the DSN key are refused.

  2. Turn on source maps in your build

    Next.js: productionBrowserSourceMaps: true. Vite: build.sourcemap: true (or "hidden"). Details below.

  3. Upload right after the build

    terminal or build script
    npx bugotter sourcemaps .next --release $(git rev-parse --short HEAD) --delete
    # Vite: npx bugotter sourcemaps dist --release $(git rev-parse --short HEAD) --delete
  4. Tag errors with the same release

    Add the release to the script tag so browser errors carry it, and to Sentry.init if you send errors from an SDK:

    script tag
    <script defer src="https://bugotter.com/bo.js" data-app="YOUR_APP_ID" data-release="c0ffee1"></script>

What the CLI does

  • Finds every .js.map (also .cjs.map and .mjs.map) in the folder. With no folder given, it uses .next if it exists, else dist.
  • Works out the path the browser loads each bundle from. For .next it looks in .next/static and prefixes /_next/; for anything else it prefixes /. Change it with --url-prefix.
  • Picks the release: --release if you pass one, else the first 12 characters of VERCEL_GIT_COMMIT_SHA, GITHUB_SHA, CF_PAGES_COMMIT_SHA or COMMIT_REF (Netlify), else git rev-parse --short HEAD.
  • Uploads each map gzipped, prints what failed, and with --delete removes each map once it’s uploaded. It exits with an error if any upload failed, so CI notices.

On CI, let the CLI pick the release and set the script tag from the same variable, so they can’t drift. On Vercel that’s VERCEL_GIT_COMMIT_SHA cut to 12 characters.

Prefer raw HTTP? Each map is one request: POST /api/<project>/sourcemaps?file=/assets/index-3f2a1b9c.js&release=… with authorization: Bearer <upload key> and the map as the body (gzip welcome).

Next.js

Next.js doesn’t build browser source maps for production by default, to avoid leaking your source. Turn them on with one flag. Its docs note two things:

  • It can make next build slower and use more memory.
  • The maps are written next to the JavaScript files and served automatically when requested. That’s why you delete them after upload.
next.config.ts
import type { NextConfig } from "next";

const config: NextConfig = {
  productionBrowserSourceMaps: true,
};
export default config;

Then chain the upload onto the build, so the maps are gone before the output is deployed.

On Vercel, set this as the build command (or as the build script in package.json) and add BUGOTTER_PROJECT and BUGOTTER_KEY to the project’s environment variables:

package.json
{
  "scripts": {
    "build": "next build && npx bugotter sourcemaps .next --delete"
  }
}

And give the script tag the same release, so browser errors find the right maps:

app/layout.tsx (the script tag with the same release)
const release = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA?.slice(0, 12);

<script defer src="https://bugotter.com/bo.js" data-app="YOUR_APP_ID" data-release={release} />

If you serve assets from a CDN with assetPrefix, the browser loads bundles from a different path than /_next/. Pass the path part with --url-prefix so frames and maps line up.

Vite

Vite’s build.sourcemap is off by default. It takes three values:

  • true writes separate .map files.
  • "inline" puts the map inside the JavaScript as a data URI.
  • "hidden" writes separate files without the sourceMappingURL comment in the bundles.

Use "hidden". Browsers won’t go looking for the maps, and you still get files to upload. Never use "inline" in production: it ships your whole source to every visitor, and there’s no separate file to upload.

vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  build: { sourcemap: "hidden" }, // output goes to dist/ by default
});
package.json
{
  "scripts": {
    "build": "vite build && npx bugotter sourcemaps dist --delete"
  }
}

The CLI maps dist/assets/index-3f2a1b9c.js to /assets/index-3f2a1b9c.js. If your app lives under a sub-path (Vite’s base, say /app/), pass --url-prefix /app/.

The same works for Astro, SvelteKit or anything else that outputs plain bundles: point the CLI at the folder the browser files end up in.

Next.js and Vite options checked against nextjs.org/docs (productionBrowserSourceMaps) and vite.dev/config/build-options on Nov 4, 2026.

Keep your maps private

A public source map is your source code, comments and all, for anyone who opens DevTools. Sometimes that’s fine (open-source apps).

Usually it isn’t: it exposes unreleased features, internal API routes, and sometimes keys someone hard-coded “just for now”.

  • Delete after upload. --delete removes each map once it’s safely uploaded. Run it in the same build step, before deploy.
  • Hide the pointer. Vite’s "hidden" drops the sourceMappingURL comment. Belt and braces with deleting.
  • On Vercel, there’s also Protected Source Maps. It serves .map files only to people who can already access the deployment and returns 404 to everyone else. Vercel’s docs say it’s on by default for new projects; existing projects opt in. Deleting maps after upload still works with or without it.
  • Check. After a deploy, open a bundle URL with .map added. You want a 404.

Uploading is safe in the other direction too: BugOtter only accepts uploads with the app’s secret upload key, and the mapped lines it shows are scrubbed like other error data.

404

What a deployed .map URL should return.

After a deploy, open a bundle URL with .map added. Anything else means your source is public.

When maps don’t apply

You uploaded maps and the stack still says a.js:1:4821. Work down this list:

  1. Did the upload run? The CLI stops with “No .js.map files in …” when the build didn’t make any. Check the flag is on and you pointed at the right folder. An “Unknown project or upload key” error means you used the DSN key or the wrong project.
  2. Is the error newer than the upload? Maps are applied when an error arrives. Errors captured before the upload keep their minified frames; trigger a new one to test.
  3. Do the paths match? The map is stored under the path the browser loads the bundle from. A CDN, assetPrefix or Vite base changes that path; fix it with --url-prefix. Run with --verbose to see each path uploaded and compare it with a frame’s URL.
  4. Is it the right build? BugOtter strips hex content hashes (the kind Next.js uses) from file names before matching, so page-3f2a1b9c0d1e2f3a.js and its map meet as page.js. That makes the release important: without a matching release, the newest map for that file is used, which is wrong for a visitor still running last week’s bundle. Set data-release and upload with the same --release.
  5. Is the map complete? A map without sourcesContent still gives file and line, but no line of code. Some setups strip it; turn it back on if you want the code shown.
  6. Inline maps? The CLI uploads .map files only. Switch to separate files.

Questions

Do I need a release name?

It helps but isn’t required. BugOtter uses a map from the same release first, and otherwise the newest map uploaded for that file. A release name makes old and new builds of the same file impossible to mix up.

Is uploading my source maps a security risk?

They contain your original source, so treat them like code. Upload them to your error tracker with a secret key and remove them from what you deploy. BugOtter refuses uploads made with the public DSN key; only the app’s secret upload key works.

Does it work with Turbopack?

Yes. BugOtter cleans webpack:// and turbopack:/// prefixes from source paths, so frames read src/cart.ts either way.

How many maps are kept?

The newest 300 per app, up to 25 MB each after unzipping. Older ones are removed as new ones arrive, which is plenty for recent releases.

What about server errors?

The CLI uploads the maps of your browser bundles (for Next.js, everything under .next/static). Server errors sent by a Sentry SDK are grouped and shown as they arrive.

Stack traces in your own files and lines.

14 days of everything, no card needed. Plans from $10 a month, billed yearly.