Uploading Source Maps to Sentry from a Vite Build

This guide walks through emitting hidden source maps from a Vite production build, uploading them to Sentry with @sentry/vite-plugin, tagging the release so events match the maps, deleting the maps from the deploy artifact, and verifying that a production stack trace symbolicates back to your .ts source. For the cross-tool model of inline versus external versus hidden maps and the security reasoning behind not shipping them, read Source Maps and Production Debugging first, then apply the Sentry-specific wiring below.

Symbolication is not really a Vite feature; it is a contract between three parties that never meet at the same instant. The build machine holds the mapping between minified output and original source, the browser holds the release identifier and reports the crash, and Sentry holds the maps and performs the lookup potentially weeks later. The only thing binding those three moments together is the release string. If any link in that chain is missing — maps never uploaded, the pragma stripped but no upload configured, or a release tag that drifts by a single character — the whole apparatus degrades to exactly the raw minified frame you started with, and you discover it only when a real incident lands and the trace is useless.

The reason this deserves its own guide, rather than a footnote in the Vite docs, is that the failure mode is both silent and deferred. A misconfigured upload does not fail the build. It produces a green pipeline, a deployed app that behaves correctly, and a Sentry project that quietly stores unsymbolicated events. You then pay the cost at the worst possible time — mid-incident, staring at index-9f2a1c.js:1:48210 — rather than at build time when the fix would have been cheap. Everything below is engineered to move that failure forward, from the incident to the build, by verifying each link in the chain deterministically instead of trusting that a zero exit code means the maps arrived.

Vite to Sentry source map upload flow Vite build emits hidden maps, the Sentry plugin uploads them under a release, maps are deleted, and a runtime error symbolicates against the stored maps. vite build sourcemap: hidden sentry plugin upload + release delete .map from dist/ deploy to CDN Runtime error in browser event tagged release: 1.4.2+abc123 Sentry matches stored maps to symbolicate maps never served publicly
Figure: hidden maps are uploaded to Sentry under a release, deleted from the artifact, then used to symbolicate runtime events tagged with the same release.

Problem Scope

Vite minifies and chunk-splits production output, so an uncaught exception arrives in Sentry as Cannot read properties of undefined (reading 'id') at index-9f2a1c.js:1:48210 — unreadable. The fix is to emit hidden source maps, hand them to Sentry keyed by a release that matches the events your app reports, and then delete the maps so they never ship to users.

The mechanism behind the unreadable frame is worth naming precisely, because it explains why every step below is load-bearing. Vite’s production build runs Rollup, which concatenates modules into a handful of chunks, and esbuild (or Terser, if you switch minifiers) then renames every local identifier to a single character and collapses whitespace, so an entire component tree lives on line 1 of the emitted chunk. The column number — 48210 — is a byte offset into that single line, meaningful only to a decoder that holds the source map. That map is a JSON file whose mappings field is a Base64 VLQ-encoded table translating each generated (line, column) pair back into an (original file, line, column, name) tuple. Without the table, the offset is an opaque integer; with it, Cart.tsx:42:7 falls out mechanically.

There are two independent problems to solve here, and conflating them causes most of the confusion in practice. The first is emitting maps that are correct and complete. The second is making those maps reachable by Sentry at symbolication time under a key that matches the crash. Emitting a flawless map that Sentry never receives — or receives under the wrong release — is indistinguishable, at incident time, from emitting no map at all. The rest of this guide treats those as two separate checkpoints rather than one setting.

Minified frame versus symbolicated frame Without maps a Sentry frame reads index-9f2a1c.js line 1 column 48210; with release-keyed hidden maps the same frame resolves to the original source file, line and column. index-9f2a1c.js:1:48210unreadable minified frame Cart.tsx:42:7symbolicated via release maps
Figure: the whole point — a release-keyed hidden map turns the left frame into the right one.

Prerequisites & Reproducible Setup

You need Vite 5.x or 6.x, @sentry/vite-plugin 2.x for upload, and @sentry/browser (or the framework SDK such as @sentry/react) for runtime reporting and release tagging. Node 18, 20, or 22 all work. The plugin is a build-time dependency only, so it belongs in devDependencies; the SDK ships to the browser and belongs in dependencies. Keeping that split honest matters because the auth token lives on the plugin side and must never be bundled into anything the browser can download.

# Vite 5.x / 6.x, Node 20+
npm install --save-dev @sentry/vite-plugin
npm install @sentry/browser

Create a Sentry auth token with project:releases and org:read scopes and expose it to the build environment — never commit it:

# .env.sentry-build-plugin (gitignored) or CI secret
export SENTRY_AUTH_TOKEN="sntrys_xxx"
export SENTRY_ORG="your-org"
export SENTRY_PROJECT="your-project"

The token scopes are not interchangeable, and the difference is the source of a whole category of “it uploaded nothing” tickets. project:releases authorizes creating a release and attaching artifacts to it; org:read lets the plugin resolve your organization slug and confirm the project exists before it starts streaming files. A token missing project:releases is the classic silent failure: the plugin authenticates against the API, begins the upload, receives a permission error per file, and — depending on version and options — still lets the build exit zero because the surrounding Vite build succeeded. Provision the token once, scope it narrowly, and treat it exactly like a deploy credential. It can write to your Sentry project, so it belongs in a CI secret store, never in vite.config.ts and never in a committed .env. The .env.sentry-build-plugin filename is the one the plugin auto-loads, which is convenient locally but is exactly the file you must add to .gitignore before your first commit.

Two packages and an auth token @sentry/vite-plugin handles the build-time upload, @sentry/browser handles runtime reporting and release tagging, and a Sentry auth token with project:releases and org:read scopes authorizes the upload. @sentry/vite-pluginbuild-time upload @sentry/browserruntime + release tag auth tokenproject:releases, never committed Build-side and runtime-side, plus a scoped token
Figure: one package uploads, one reports; the token must carry project:releases or the upload silently no-ops.

Diagnosis Workflow

Before wiring the plugin, confirm what is actually wrong, in order:

Four-step Sentry symbolication diagnosis Check the stack frames for minified paths, check the event Release tag matches a release with artifacts, inspect the deployed bundle for a sourceMappingURL pragma, then check the release Artifacts tab for uploaded maps. 1 framesminified? 2 release tagmatches? 3 bundle pragmapublic map? 4 artifactsuploaded? A mismatched Release tag is the single most common failure
Figure: step 2 — the release tag — is where most symbolication failures actually live.
  1. Are the frames actually minified? Open the Sentry issue and read the stack frames. If they show minified index-*.js paths with single-digit line numbers and five-digit columns, no maps are associated with the event and the symbolication step never ran. If instead the frames already resolve to .tsx files, your problem is elsewhere — you are chasing a maps issue that does not exist. This is the cheapest check and the one to do first, because it disambiguates “maps missing” from “maps present but wrong file.”

  2. Does the Release tag match a release with artifacts? Check the event’s Release tag. If it is empty, the runtime SDK never received a release string and Sentry has no key to look maps up by. If it is populated but does not match any release that actually has artifacts attached, symbolication still cannot occur even though the upload succeeded — the two sides simply disagree on the key. Mismatched release strings are the single most common failure in this entire flow, which is why the diagram above flags step 2 specifically. Confirm the fix by copying the tag value verbatim and searching Releases for it; a leading myapp@ prefix on one side but not the other is enough to break it.

  3. Is the deployed bundle leaking a public map? Inspect the bundle you actually shipped: curl -s https://app.example.com/assets/index-*.js | tail -1. If the last line contains a //# sourceMappingURL pragma, you are pointing browsers at a publicly fetchable .map — the wrong outcome for two reasons: it exposes your source, and it means you are on sourcemap: true rather than 'hidden'. Switch the build option to 'hidden' so the map is written but the pragma is omitted. Confirm by re-running the same curl after redeploy and seeing no pragma on the last line.

  4. Did the artifacts actually upload, and do their paths match? In Sentry, open the release’s Artifacts tab. If it is empty, the build never uploaded — revisit the token scopes and the plugin config before touching anything else. If it lists .map files but events still fail to symbolicate, the problem is path resolution: the sources recorded in the map, or the artifact path prefix, does not line up with the URLs the bundle is served from. Sentry matches artifacts to frames by URL, so a dist/-relative artifact name and a /assets/-served bundle will not meet in the middle without a matching prefix.

Complete Annotated Configuration

One release string used on both sides A single release id derived from the git SHA is passed to the Sentry plugin for the upload and injected into the client so Sentry.init tags events with the identical string; if the two differ, Sentry cannot associate the maps with events. release = git SHAone value plugin upload (release name) Sentry.init (release tag) must be identical
Figure: derive the release once and feed both the upload and the runtime SDK — a drift here breaks symbolication silently.
// vite.config.ts — Vite 5.x / 6.x with @sentry/vite-plugin 2.x, Node 20+
import { defineConfig } from 'vite';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { execSync } from 'node:child_process';

// Derive one stable release id used by BOTH the upload and the runtime SDK.
const release =
  process.env.SENTRY_RELEASE ??
  `myapp@${execSync('git rev-parse --short HEAD').toString().trim()}`;

export default defineConfig({
  // Expose the release to client code so Sentry.init can tag events with it.
  define: {
    __SENTRY_RELEASE__: JSON.stringify(release),
  },
  build: {
    // 'hidden' writes .map files but appends NO sourceMappingURL pragma,
    // so browsers never fetch them and they are not publicly discoverable.
    sourcemap: 'hidden',
  },
  plugins: [
    sentryVitePlugin({
      org: process.env.SENTRY_ORG,
      project: process.env.SENTRY_PROJECT,
      authToken: process.env.SENTRY_AUTH_TOKEN,
      // Tie uploaded artifacts to the exact release the runtime reports.
      release: { name: release },
      sourcemaps: {
        // Upload every emitted map, then delete them from the build output
        // so the deploy artifact ships no .map files at all.
        assets: './dist/**',
        filesToDeleteAfterUpload: ['./dist/**/*.map'],
      },
      // telemetry off to avoid sending build metadata to Sentry.
      telemetry: false,
    }),
  ],
});
// src/sentry.ts — initialize the runtime SDK with the SAME release string
import * as Sentry from '@sentry/browser';

declare const __SENTRY_RELEASE__: string;

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  // MUST equal the release name passed to sentryVitePlugin above, or
  // Sentry cannot associate the uploaded maps with incoming events.
  release: __SENTRY_RELEASE__,
  environment: import.meta.env.MODE,
});

Import src/sentry.ts at the very top of your entry (main.tsx) so initialization precedes any code that can throw. An error thrown by a module imported above the Sentry init call cannot be captured — the handler is not installed yet — and those early-boot errors are precisely the ones you most want symbolicated, because they take the whole app down rather than one interaction.

A few of the config choices deserve their reasoning spelled out, since copying them without understanding is how drift creeps back in. The release constant is computed exactly once and threaded to both the plugin (as release.name) and the client (via the define replacement). That single-source-of-truth pattern is the whole defense against the mismatch failure: there is no second git rev-parse call on the runtime side that could resolve differently. The define block performs a compile-time string substitution — Vite replaces every textual occurrence of __SENTRY_RELEASE__ in the bundle with the JSON-stringified literal — so the value is frozen into the shipped code at build time rather than read from the environment in the browser, where process.env does not exist. Setting telemetry: false is optional but keeps the plugin from reporting build metadata back to Sentry; drop it if you want the plugin’s own diagnostics.

The assets: './dist/**' glob tells the plugin which emitted files to scan for maps, and filesToDeleteAfterUpload runs strictly after a successful upload of those files. Ordering is the point: the plugin uploads first and deletes second, within the same build step, so there is no window in which a map exists on disk after the artifact is considered final. If you widen assets to include files outside dist/, widen the deletion glob to match, or you will ship maps the plugin scanned but did not clean up.

Verification

After vite build, confirm the pipeline end to end:

Three verification checks after the build Confirm no .map files remain in dist, confirm no bundle carries a sourceMappingURL pragma, and confirm the Sentry release exists with uploaded artifacts. find dist -name '*.map' no output grep sourceMappingURL dist/assets no output sentry-cli releases files list artifacts present
Figure: two absences (no maps, no pragma) and one presence (uploaded artifacts) confirm the pipeline.
# 1. The plugin should have deleted every map from the artifact:
find dist -name '*.map'        # expect: no output

# 2. No bundle should carry a sourceMappingURL pragma:
grep -rl "sourceMappingURL" dist/assets   # expect: no output

# 3. The release should exist with artifacts uploaded:
npx sentry-cli releases files "$SENTRY_RELEASE" list

Then trigger a real symbolication test. Add a temporary throwing handler, deploy, click it in production, and open the resulting Sentry issue. A correctly wired pipeline shows the frame resolved to src/...tsx with the original line, column, and surrounding code context, and the event’s Release tag matching the release you uploaded under. Remove the test handler afterward.

The three shell checks and the browser test verify different links in the chain, and it is worth being deliberate about which is which rather than treating them as one ritual. find dist -name '*.map' proves the deletion step ran — a non-empty result means maps would have shipped in the artifact. The grep for sourceMappingURL proves no bundle points a browser at a map, which is a distinct guarantee: you could delete the maps yet still leave a dangling pragma that produces 404s in the console, or leave a pragma pointing at maps you forgot to delete. sentry-cli releases files list proves the upload half of the contract independently of the build’s exit code, closing the silent-no-op gap. Only the browser test exercises the full path — emit, upload, tag, match, symbolicate — end to end, which is why it is worth the few minutes even when the three shell checks pass. A useful habit is to keep the throwing handler behind a query-string flag (?__sentrytest=1) so you can re-run the end-to-end check after any future change to the build without editing code.

Gotchas & Edge Cases

Four Sentry upload edge cases Release string drift from detached HEAD breaks matching; a separate rm racing the plugin deletes maps before upload; a token without project:releases uploads nothing silently; and sourcesContent embeds proprietary code into the maps. release string drift detached HEAD computes a different value — pin one SENTRY_RELEASE env var in CI. rm races the plugin a separate delete step can beat the upload — let filesToDeleteAfterUpload own deletion. missing auth scopes a token without project:releases uploads nothing but exits 0 — check the Artifacts tab. sourcesContent embeds code fine if maps stay private; else set sourcemapExcludeSources + source links.
Figure: two "the pipeline raced or drifted" traps and two "it silently did nothing" traps.

Most of these share a shape: the pipeline either raced with itself or drifted apart, and in both cases the build stays green while symbolication quietly stops working. Read each as symptom, root cause, and confirmation rather than a rule to memorize.

  • Release string drift. Symptom: uploads succeed, events carry a Release tag, yet nothing symbolicates. Root cause: the build derives the release from git rev-parse, but CI checks out a detached HEAD or a shallow clone, so the SHA the plugin sees differs from the SHA baked into the client — or the two sides compute the value at different moments and disagree. Fix: compute the release once in CI, export it as SENTRY_RELEASE, and let both the plugin and the define block read that single variable rather than shelling out to git twice. Confirm: the event’s Release tag is byte-for-byte identical to the release name in the Artifacts tab.
  • Maps deleted before upload finishes. Symptom: the Artifacts tab is empty or partial despite a “build succeeded” log. Root cause: a separate rm -rf dist/**/*.map in a pre-deploy script races the plugin and can win, deleting maps before the upload streams them. Fix: delete nothing yourself — filesToDeleteAfterUpload runs inside the plugin strictly after upload, so let it own deletion entirely and remove any competing rm. Confirm: the Artifacts tab lists one .map per emitted chunk after a clean CI run.
  • Missing auth scopes. Symptom: a clean build, a deployed app, and an empty Artifacts tab. Root cause: a token without project:releases fails the upload per file but the surrounding build still exits zero, so nothing surfaces the error. Fix: re-issue the token with project:releases and org:read. Confirm: never trust the exit code here — check that the release’s Artifacts tab is non-empty, which is the only signal that actually distinguishes success from a silent no-op.
  • sourcesContent and proprietary code. Symptom: not a failure so much as a disclosure concern — your original TypeScript is embedded verbatim inside the maps. Root cause: Vite embeds original source into hidden maps by default via sourcesContent. Fix and trade-off: because these maps go only to Sentry and are deleted from the deploy artifact, embedding is usually acceptable and gives you the richest code context on every frame; if your Sentry org is shared broadly and that context is sensitive, set rollupOptions.output.sourcemapExcludeSources: true and rely on Sentry’s source-link integration to pull source from your repository at view time instead. Confirm: open an uploaded .map locally and check whether the sourcesContent array is present or empty.

CI Integration

The single most important thing CI must do is fix the release string so it cannot drift, and the cleanest way is to compute it in one shell step and export it for every subsequent step to consume. The plugin and the client build both read SENTRY_RELEASE from the environment, so once it is set, neither side re-derives it. The following GitHub Actions job shows the whole shape — resolve the SHA, expose the secrets, build, and let the plugin upload as part of vite build.

# .github/workflows/deploy.yml — GitHub Actions, actions/checkout@v4
name: build-and-upload
on: { push: { branches: [main] } }
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history so rev-parse is stable, not a shallow SHA
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      # Pin ONE release value for both the upload and the runtime SDK.
      - run: echo "SENTRY_RELEASE=myapp@${GITHUB_SHA::12}" >> "$GITHUB_ENV"
      - run: npm ci
      # The plugin uploads and deletes maps as a side effect of the build.
      - run: npm run build
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

Two details in that job are load-bearing. fetch-depth: 0 restores full git history so git rev-parse — if you fall back to it locally — resolves the real commit rather than a grafted shallow one; more importantly, pinning SENTRY_RELEASE from GITHUB_SHA sidesteps git entirely and removes the drift risk at its source. Passing the three Sentry variables as step env rather than repository-wide keeps the token scoped to the one step that needs it, so it never leaks into unrelated tooling or logs.

Performance and Build-Time Considerations

Enabling sourcemap: 'hidden' adds real work to the production build. Rollup must emit the map alongside every chunk, which roughly doubles the number of files written and adds the VLQ-encoding pass over the full module graph; on a large app this is typically a single-digit-percentage increase in build time, but the emitted dist/ can grow several-fold in raw bytes before the maps are deleted. Because the maps are removed after upload, none of that size reaches your CDN — the cost is entirely local disk and upload bandwidth in CI, not runtime payload. If your CI runner is disk-constrained, be aware that the peak footprint occurs between emit and delete, when both the bundles and their maps coexist on disk.

The upload itself is network-bound and proportional to total map size. The plugin deduplicates by content hash where it can, so unchanged chunks across releases may skip re-upload, but a release that touches shared vendor chunks will re-upload broadly. For most apps this is a few seconds; for very large bundles it can dominate the build step, which is a reason to keep source map upload on the deploy path only and out of pull-request preview builds where the maps are never queried.

When Not to Wire This Up

This setup is right for a client app whose crashes you triage in Sentry. It is the wrong tool in a few situations worth naming so you do not cargo-cult it. If you never send events to Sentry — no runtime SDK, no DSN — uploading maps stores artifacts nothing will ever query, so skip it entirely. If your app is server-rendered and the stack traces you care about are Node-side, the maps that matter are the server bundle’s, uploaded under the same release but scanned from the server output directory, not the client dist/. And in a monorepo shipping several apps, run the plugin once per app with distinct project values and distinct release names; a single upload spanning multiple apps produces artifacts whose paths collide and symbolicate against the wrong bundle. In each case the deciding question is the same one the whole guide turns on: will an event ever arrive tagged with a release these exact maps were uploaded under?