Capturing Next.js route handler and Server Action errors by digest

One instrumentation hook covers route handlers, Server Actions and RSC renders. Wire it to a capture API, keep the digest, and know what Edge and source maps cost you.

In production Next.js refuses to send your server error to the browser. React replaces the message with a generic one and attaches a digest — a hash — and that hash is the only string a user can read back to you over support chat. So the integration that matters isn’t “post errors somewhere”, it’s “post errors somewhere with the digest attached”. On Infrai that’s one POST /v1/errors/capture from instrumentation.ts, which runs in both the Node.js and Edge runtimes, so a single file covers the whole server surface.

There’s one hook, and it sees more than you’d expect. onRequestError (stable since Next.js 15.0) fires for route handlers, Server Actions, Server Component renders and proxy code alike, and its context.routeType tells you which of the four you’re looking at. That field is the best fingerprint prefix Next.js will ever hand you for free.

What the client is actually holding

Where it brokecontext.routeTypeWhat the browser getsYour join key
app/api/*/route.tsroutewhatever your catch returnedyour own request id
Server Action, uncaughtactiongeneric message + digestthe digest
Server Action, caught by younot reportedthe state object you returnedan id you mint
Server Component renderrendererror.tsx boundary + digestthe digest

That third row is the one people trip over. Catch the error inside your action to show a friendly form message and Next.js never sees it, so onRequestError never fires and no digest is ever generated — the correlation id has to be yours.

One hook, every route type

// instrumentation.ts — root of the project (or src/).
import { type Instrumentation } from "next";
import { capture } from "./lib/capture";

export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
  const error = err instanceof Error ? err : new Error(String(err));
  const digest = typeof err === "object" && err !== null && "digest" in err ? String(err.digest) : "none";
  await capture(error, {
    scope: `${context.routeType}:${context.routePath}`,
    digest,
    path: request.path,
    method: request.method,
  });
};

Note the await. Next.js documents that async work in this hook must be awaited or it may never finish, and an unawaited capture on a serverless platform is a report that dies with the invocation. The scope is built from routePath (/app/orders/[id]/route) rather than request.path (/orders/8812), because a fingerprint containing a real order id gives you one group per order.

The reporter

// lib/capture.ts — no dependency, works in both runtimes.
const CAPTURE_URL = "https://api.infrai.cc/v1/errors/capture";

type Meta = { scope: string; digest: string; path: string; method: string };

export async function capture(error: Error, meta: Meta): Promise<string | null> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) return null;
  const head = `${error.name}: ${error.message}`;
  try {
    const res = await fetch(CAPTURE_URL, {
      method: "POST",
      headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
      cache: "no-store",
      signal: AbortSignal.timeout(2500),
      body: JSON.stringify({
        message: `${head}\n[digest=${meta.digest} ${meta.method} ${meta.path} runtime=${process.env.NEXT_RUNTIME ?? "nodejs"}]\n${error.stack ?? ""}`.slice(0, 8000),
        exception: error.name,
        fingerprint: `${meta.scope}:${error.name}`,
        environment: process.env.NODE_ENV ?? "development",
        release: process.env.APP_RELEASE ?? "dev",
      }),
    });
    if (!res.ok) { console.error("[capture] HTTP", res.status); return null; }
    const json = (await res.json()) as { data: { error_group_id: string } };
    return json.data.error_group_id;
  } catch (e) {
    console.error("[capture] unreachable", e);
    return null;
  }
}

The digest goes in message, never in fingerprint. It’s a hash of the specific error, so fingerprinting on it would split one broken code path into a group per variant — but keeping it in the searchable text means a support ticket saying “it showed me 2f9a1c” is a one-call lookup.

The action you catch yourself

// app/orders/actions.ts
"use server";
import { randomUUID } from "node:crypto";
import { capture } from "@/lib/capture";

export async function cancelOrder(_prev: unknown, form: FormData) {
  const ref = randomUUID().slice(0, 8);
  try {
    const id = String(form.get("orderId") ?? "");
    if (!/^\d+$/.test(id)) throw new TypeError(`bad order id: ${id}`);
    return { ok: true as const, ref };
  } catch (err) {
    await capture(err instanceof Error ? err : new Error(String(err)), {
      scope: "action:/app/orders/actions#cancelOrder",
      digest: `self:${ref}`,
      path: "/orders",
      method: "POST",
    });
    return { ok: false as const, ref, message: `Something broke. Quote reference ${ref}.` };
  }
}

Mint the reference before the work, show it in the failure message, and send it as the digest. You’ve now rebuilt the property React was giving you for the uncaught case, and both kinds of failure are searchable the same way.

Edge: what actually changes

// app/api/ping/route.ts
export const runtime = "edge";

export async function GET(): Promise<Response> {
  const started = Date.now();
  try {
    const upstream = await fetch("https://example.com/health", { signal: AbortSignal.timeout(1500) });
    if (!upstream.ok) throw new Error(`upstream ${upstream.status}`);
    return Response.json({ ok: true, ms: Date.now() - started });
  } catch (err) {
    throw err;                    // let it reach onRequestError instead of swallowing it
  }
}

The Edge runtime has fetch, AbortSignal, process.env and a polyfilled AsyncLocalStorage, which is everything the reporter above touches — that’s the whole reason a plain REST capture travels well here while an SDK built on Node internals doesn’t. What you lose is the process itself: no process.on('uncaughtException'), no unhandledRejection hook, no filesystem, no require. Anything that escapes a request in Edge is gone, so onRequestError and explicit try/catch are the entire safety net.

Don’t fire the capture and return without awaiting it. The isolate can be frozen the moment the response is flushed, and an unawaited POST is simply never sent.

Source maps, honestly

This route stores no frames. Whatever you pass as exception is normalised to {"type": "Message", "value": "…", "stacktrace": []}, so uploading source maps to Infrai would do nothing — there’s nothing to map them onto. The useful move is mapping the stack before it leaves your process:

// next.config.mjs
const nextConfig = {
  experimental: { serverSourceMaps: true },
};
export default nextConfig;
NODE_OPTIONS=--enable-source-maps npx next start

Node rewrites error.stack through the source map at throw time, so the text you send already names app/orders/actions.ts:31 instead of a chunk offset. For server code that gets you most of the way. For minified browser stacks it does nothing, and that’s a real limitation: if a large share of your errors are client-side, buy Sentry, whose Next.js integration uploads maps at build time and resolves those frames properly. Bugsnag is the other credible pick if you want the same thing with a simpler pricing model.

Pick on evidence, not on taste: count where your errors are.

Proving it end to end

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: bad order id: abc\n[digest=2f9a1c74b8 POST /orders runtime=nodejs]\n    at cancelOrder (/app/app/orders/actions.ts:31:11)",
    "exception": "TypeError",
    "fingerprint": "action:/app/orders/actions#cancelOrder:TypeError",
    "environment": "production",
    "release": "web@2026.07.25"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_aKfkwIcCO1OlEgQ1MGNPmqWt",
    "fingerprint": "ab9a3c6fcf9caddf0ef399e087444fb8e18b17008c50d26d1383e8bce9048fa7",
    "error_group_id": "errgrp_gZoNovGJ6rrZilsJcRzb1NRm",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_aKfkwIcCO1OlEgQ1MGNPmqWt"
  }
}

Then the support-desk query, which costs nothing because every read route in this namespace is free:

curl -sS "https://api.infrai.cc/v1/errors/search?q=2f9a1c74b8&limit=1" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Captures are metered at $0.00005 per event, verified 2026-07-26, and a new account starts with $2 of free credit — call it 39,999 events before anything is charged. Rates here move downward over time and campaigns run, so read the live figure rather than trusting a printed one:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | jq '.capabilities[] | select(.id=="errors.capture") | .billing'

The structural argument survives any price change: reads are free, writes are metered per event, and the key doing this also runs your queues, cron jobs, object storage and transactional email — so the follow-up to a captured Server Action failure doesn’t need a fifth vendor account.

References

Browse more errors developer guides