Minified stack traces in production React and Next.js builds
Why production JS traces are unreadable, the three ways source maps go missing, and exactly what Infrai's capture route stores when you send a stack trace.
A production trace reads as t is not a function at a.b (chunk-9f2c.js:1:48211) because the browser never ran your source. The bundler renamed identifiers, merged modules onto one line and stripped the file structure; the .map file that reverses that either was never generated, was never shipped, or no longer matches the bundle that threw. Symbolication is a build-pipeline problem. Infrai’s errors API is on the receiving end of it — it takes the line you send and groups it, and it does not resolve frames for you.
That boundary matters enough to state before anything else: POST /v1/errors/capture stores a message, not a parsed stack. Below is what each of the three failure causes actually looks like, how to resolve frames yourself with the map your build already produced, and where a specialist tracker is the honest recommendation instead.
Three different reasons the frames are gone
The first is the default. A Next.js production build doesn’t emit browser source maps unless you set productionBrowserSourceMaps: true in next.config.js, so a fresh app ships minified client chunks and no maps at all. Nothing is broken; nothing was ever generated.
The second is deliberate deletion. Maps embed your original source, and publishing them at a guessable URL publishes your frontend code, so plenty of teams strip *.map during deploy. Reasonable — but the tracker then has nothing to work with either.
The third is the one that eats an afternoon: the maps exist, they were uploaded, and the tracker still shows minified names. Almost always the bundle was rebuilt after the upload, so content hashes no longer line up, or the release identifier attached to the event doesn’t match the release the maps were filed under. Sentry’s troubleshooting guide for Next.js is largely a catalogue of this mismatch, and it’s worth reading even if you never send them a byte.
Server-side traces are a separate story again. Next.js minifies server bundles too, and there’s a long-running report (vercel/next.js issue 74646) that a prepareStackTrace patch left server traces minified even where maps were present.
Where symbolication can happen
| Where frames get resolved | What it costs you | When to choose it |
|---|---|---|
| In the browser, from public maps | Your source is downloadable by anyone | Almost never in a commercial app |
| In your build or CI, before sending | ~40 lines of Node and the map you already have | You want one clean line in the tracker and no vendor upload step |
| In the tracker, from uploaded maps | A CI upload step and per-release map storage | You want frame-level views, suspect commits, release health |
| Nowhere | Grouping keyed on minified junk | Never on purpose |
Row three is what Sentry, Bugsnag and Rollbar sell, and they’re good at it. Row two is what pairs with Infrai.
What the capture route actually stores
Send a symbolicated line as the message:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "TypeError: Cannot read properties of null (reading \"total\") at CartSummary (src/components/CartSummary.tsx:38:22)",
"exception": "TypeError",
"fingerprint": "web:CartSummary:TypeError:cart-total",
"environment": "production",
"release": "web-2026.07.12"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_xxzdiHMjLbE7jHSYiAIIeMrc",
"fingerprint": "6979ef1bef00379ef69cd78db1afaa0b5df16d635f49fdd75d2836d47999bf72",
"error_group_id": "errgrp_Aareim7M8ldNcRlLaDr0WL4L",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_xxzdiHMjLbE7jHSYiAIIeMrc"
}
}
Now read the stored event back and look at what survived — this is the part that decides whether the route fits your problem:
curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_xxzdiHMjLbE7jHSYiAIIeMrc" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"event_id": "evt_err_xxzdiHMjLbE7jHSYiAIIeMrc",
"level": "error",
"exception": {
"type": "Message",
"value": "TypeError: Cannot read properties of null (reading \"total\") at CartSummary (src/components/CartSummary.tsx:38:22)",
"stacktrace": []
},
"environment": "production",
"release": "web-2026.07.12",
"fingerprint_source": "user",
"breadcrumbs": []
}
}
stacktrace comes back empty and exception.type is the literal string Message, whatever exception class you named on the way in. The store keeps your text and the metadata around it; it doesn’t parse frames, and there’s no route for uploading source maps against a release. So the limitation is plain: if a triage view with expandable frames and suspect-commit links is what you’re shopping for, stick with Sentry — the pairing that makes sense here is symbolicating in your build and sending one already-readable line.
One practical detail: the group title is the first ~120 characters of your message, so put the class, the message and the top frame first and drop the rest of the stack.
Resolving a frame with the map you already have
Node 22 can do this with the source-map package (npm i source-map) and the .map your bundler wrote. This script takes a minified position, resolves it, and captures the readable version:
import { readFile } from "node:fs/promises";
import { SourceMapConsumer } from "source-map";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is not set");
const mapPath = process.argv[2]; // e.g. .next/static/chunks/4711-9f2c.js.map
const line = Number(process.argv[3]);
const column = Number(process.argv[4]);
if (!mapPath || !Number.isFinite(line) || !Number.isFinite(column)) {
throw new Error("usage: node symbolicate.mjs <map-file> <line> <column>");
}
const raw = JSON.parse(await readFile(mapPath, "utf8"));
const consumer = await new SourceMapConsumer(raw);
const origin = consumer.originalPositionFor({ line, column });
consumer.destroy();
const where = origin.source
? `${origin.source.replace(/^webpack:\/\/_N_E\//, "")}:${origin.line}:${origin.column}`
: `${mapPath.replace(/\.map$/, "")}:${line}:${column}`;
const fn = origin.name ?? "<anonymous>";
const res = await fetch("https://api.infrai.cc/v1/errors/capture", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
message: `TypeError: Cannot read properties of null (reading "total") at ${fn} (${where})`,
exception: "TypeError",
fingerprint: `web:${fn}:TypeError:cart-total`,
environment: "production",
release: process.env.RELEASE ?? "web-2026.07.12",
}),
});
if (!res.ok) {
throw new Error(`capture failed: HTTP ${res.status} ${await res.text()}`);
}
const { data } = await res.json();
console.log(`${data.event_id} -> ${data.error_group_id} (new group: ${data.is_new_group})`);
The fingerprint is the interesting half. Because it’s derived from the resolved function name rather than the minified one, it survives the next deploy — a rebuilt chunk renames a.b to c.d and the group would otherwise split in two.
Server-side traces on Node 22
For the server half of a Next.js app you don’t need any of that, because Node resolves maps itself when you ask it to:
NODE_OPTIONS="--enable-source-maps" node .next/standalone/server.js
That flag makes error.stack carry original file names and line numbers inside the process, which means whatever you pass to capture is already readable. It costs a little startup time and some memory to hold the maps — measurably, though under 100ms on a small app — and it needs the .map files to be present next to the emitted JS at runtime.
Cost and the shape of the bill
Capture is the only metered route in this namespace: $0.00005 per call, verified 2026-07-26, with reads (get, groups, group_detail, events, list, search) free and rate-limited. New accounts get $2 in credit, which is roughly 39,999 captures before you’ve spent anything. Rates here move down over time and discounts run, so check rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; caps=json.load(sys.stdin)['capabilities']; print([c['billing'] for c in caps if c['id']=='errors.capture'])"
The structural point outlives any figure. Errors bills per captured event, reads are free, and the same key already reaches the queue, cron, storage and email routes on the account — so the follow-on work after an alert doesn’t need a second vendor, a second SDK or a second invoice.