Sentry or a plain capture API for Next.js route handlers?
A feature-by-feature comparison for Next.js server code, and the TypeScript to wire a dependency-free capture call into app/api and instrumentation.ts.
If the errors you actually lose sleep over happen in app/api route handlers, server actions and server components, the honest answer is that you don’t need an SDK — you need one authenticated POST per failure and somewhere to group them. Infrai’s errors namespace is that: POST /v1/errors/capture with five fields, no package to install, nothing wrapping your next.config.js, and zero bytes added to the client bundle.
If your hardest bugs are in the browser instead, Sentry wins that argument outright and this page won’t pretend otherwise. Source maps uploaded at build time, minified frames resolved back to your components, replay of the session that produced the white screen — none of that has a counterpart in a REST capture route, and rebuilding it isn’t a weekend project.
The comparison, without the marketing column
| Sentry Next.js SDK | Plain capture API | |
|---|---|---|
| Install footprint | wizard, config wrapper, client + server + edge config files | one fetch, no dependency |
| Client bundle cost | tens of KB | 0 KB — server-side only |
| Minified browser traces | resolved via uploaded source maps | not supported |
| Server stack traces | frame-level, with in-app markers | plain text inside message |
| Tracing and session replay | yes | no |
| Release health | first-class, with adoption and crash-free rates | release is a string on the event |
| Alerting | rules, digests, on-call routing | poll the free read routes yourself |
| Pricing model | monthly event quota per plan | metered per captured event, reads free |
| Leaving | rip out the SDK and its build hooks | delete one function |
Read that table as a decision aid, not a scoreboard. Rows three through six are Sentry’s product, and if you need two or more of them you should buy it. Rows one, two and nine are why teams shipping a mostly-server Next.js app keep asking whether an SDK is warranted at all.
The reporter
One file, no imports beyond Node’s global fetch, safe to call from any runtime Next.js gives you.
const API = "https://api.infrai.cc";
type ReportOptions = { scope?: string; release?: string };
export async function reportError(err: unknown, opts: ReportOptions = {}): Promise<string | null> {
const key = process.env.INFRAI_API_KEY;
if (!key) {
console.error("[errors] INFRAI_API_KEY missing — not reporting");
return null;
}
const error = err instanceof Error ? err : new Error(String(err));
const scope = opts.scope ?? "app";
const body = {
message: (error.stack ?? error.message).slice(0, 8000),
exception: error.name,
fingerprint: `${scope}:${error.name}`,
environment: process.env.NODE_ENV ?? "development",
release: opts.release ?? process.env.NEXT_PUBLIC_APP_RELEASE ?? "dev",
};
try {
const res = await fetch(`${API}/v1/errors/capture`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(2500),
});
if (!res.ok) {
console.error(`[errors] capture returned HTTP ${res.status}`);
return null;
}
const json = (await res.json()) as { data: { error_group_id: string } };
return json.data.error_group_id;
} catch (sendFailure) {
console.error("[errors] capture unreachable", sendFailure);
return null;
}
}
The 2.5-second timeout is not decoration. A capture call that hangs inside a route handler turns a 500 into a 500 that took thirty seconds, and monitoring that degrades the thing it monitors gets removed within a week.
Wiring it into a route handler
import { NextResponse } from "next/server";
import { after } from "next/server";
import { reportError } from "@/lib/report-error";
export const runtime = "nodejs";
export async function POST(request: Request): Promise<NextResponse> {
try {
const payload = (await request.json()) as { invoiceId?: string };
if (!payload.invoiceId) {
return NextResponse.json({ error: "invoiceId is required" }, { status: 400 });
}
const invoice = await settleInvoice(payload.invoiceId);
return NextResponse.json(invoice);
} catch (err) {
after(() => reportError(err, { scope: "POST /api/invoices/settle" }));
return NextResponse.json({ error: "internal error" }, { status: 500 });
}
}
async function settleInvoice(id: string): Promise<{ id: string; status: string }> {
if (!/^inv_[a-z0-9]{6,}$/.test(id)) {
throw new TypeError(`malformed invoice id: ${id}`);
}
return { id, status: "settled" };
}
after() runs the callback once the response has been flushed, so the user’s request isn’t waiting on your error reporting. The scope string is the route template — not the concrete URL — because a fingerprint built from /api/invoices/inv_9f21 would open a new group per invoice, and one bad deploy would leave you with a five-thousand-row issue list describing a single mistake.
Only 4xx you caused belongs in the 400 branch. Don’t capture it.
For everything the framework catches before your code sees it, Next.js 15 exposes an onRequestError hook:
import type { Instrumentation } from "next";
import { reportError } from "@/lib/report-error";
export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
await reportError(err, { scope: `${context.routerKind}:${request.path}` });
};
What the store keeps, and what it drops
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: malformed invoice id: 9f21\n at settleInvoice (/app/app/api/invoices/settle/route.ts:24:11)",
"exception": "TypeError",
"fingerprint": "POST /api/invoices/settle:TypeError",
"environment": "production",
"release": "2026.07.6"
}'
Now read an event back and look closely at the exception object:
curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_UpWSnXBBW7twDTS7NswKQyw7" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"event_id": "evt_err_UpWSnXBBW7twDTS7NswKQyw7",
"level": "error",
"exception": {
"type": "Message",
"value": "TypeError: Cannot read properties of undefined (reading \"id\")",
"stacktrace": []
},
"environment": "production",
"release": "2026.07.4",
"fingerprint_source": "default"
}
}
stacktrace is empty, and it always will be — the route stores what you send as text and does not parse frames out of it. That’s the single most important thing to understand before choosing this over an SDK: send error.stack in message, because that string is your only trace. It stays searchable, it renders fine, and it will never become a clickable frame list with your source beside it.
Cost, and how to check it yourself
Capture is metered at $0.00005 per event, verified 2026-07-26, with $2 of free credit on a new account and free reads. Quota-based plans behave differently under stress: they’re free until a bad release burns your monthly allowance in an afternoon, then they drop events. Metered billing charges you for that afternoon instead. Neither is strictly better — know which failure mode you’d rather have.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "errors.capture") | .billing'
Rates in this catalogue move down over time, so treat the printed figure as an upper bound and the live call as the truth.
Picking one
Choose Sentry if a meaningful share of your errors are client-side, if you want tracing across the server/client boundary, or if you’d rather configure alert rules than write a poller. Rollbar and Bugsnag make the same argument with different grouping algorithms.
Choose the plain API if your Next.js app is mostly server code, your traces are already readable, you don’t want a build-time integration, and you’d rather have the error store on the same key as your queue, cron, database and email than open a fifth account. That’s the trade-off in one sentence, and it’s a real one either way.