Export an onRequestError function from instrumentation.ts and Next.js calls it for every uncaught server-side error in your app — Server Component renders, Server Actions, Route Handlers, middleware. One file at the project root, one function, stable since Next.js 15. This is the minimal version:
// instrumentation.ts — project root (or src/), next to app/
import type { Instrumentation } from 'next'
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context
) => {
await fetch('https://errors.example.com/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: error.message,
digest: error.digest,
path: request.path,
method: request.method,
routePath: context.routePath,
routeType: context.routeType,
}),
})
}
This POSTs every uncaught server error to an HTTP endpoint, tagged with the route file it came from and the kind of work that failed. Before onRequestError, Next.js server error logging meant wrapping every Route Handler in try/catch, monkey-patching console.error, or grepping your host's function logs. Now the framework hands you each failure in one place.
What does onRequestError receive?
Three arguments: the error, read-only request info, and a context object describing where inside Next.js the error surfaced. The exact types, from the Next.js instrumentation reference:
export function onRequestError(
error: { digest: string } & Error,
request: {
path: string // resource path, e.g. /blog?name=foo
method: string // request method, e.g. GET, POST
headers: { [key: string]: string | string[] }
},
context: {
routerKind: 'Pages Router' | 'App Router'
routePath: string // route file path, e.g. /app/blog/[dynamic]
routeType: 'render' | 'route' | 'action' | 'proxy'
renderSource:
| 'react-server-components'
| 'react-server-components-payload'
| 'server-rendering'
revalidateReason: 'on-demand' | 'stale' | undefined
renderType: 'dynamic' | 'dynamic-resume'
}
): void | Promise<void>
The context fields are what turn a bare stack trace into something you can route and triage:
| Field | Values | What it tells you |
|---|---|---|
routerKind | 'App Router', 'Pages Router' | Which router served the request |
routePath | e.g. /app/blog/[slug] | The route file, not the concrete URL — the one to group on |
routeType | 'render', 'route', 'action', 'proxy' | Page render, Route Handler, Server Action, or proxy/middleware |
renderSource | 'react-server-components', 'react-server-components-payload', 'server-rendering' | Which rendering phase failed |
revalidateReason | 'on-demand', 'stale', undefined | Set when the error happened during ISR revalidation; undefined on a normal request |
renderType | 'dynamic', 'dynamic-resume' | 'dynamic-resume' means a Partial Prerendering resume |
Two details that bite people:
errormay not be the instance you threw. If the failure happened during Server Components rendering, React may have processed the error before Next.js hands it to you, soinstanceof MyCustomErrorchecks can fail. Thedigestproperty is the stable identifier — lean on it.routePathis a file path, not a URL. You get/app/blog/[slug]fromcontext.routePathand the concrete/blog/hello-world?ref=xfromrequest.path. Group by the former, debug with the latter.
onRequestError vs error.tsx vs global-error.tsx: which catches what?
They're different layers, not alternatives. onRequestError is server-side telemetry — it observes and reports, but renders nothing. error.tsx and global-error.tsx are client-side fallback UI — React error boundaries that swap a crashed subtree for something presentable. Most server failures involve both: onRequestError fires on the server, then the nearest boundary renders in the browser.
| Where it breaks | onRequestError | error.tsx | global-error.tsx |
|---|---|---|---|
| Server Component render | Yes — routeType: 'render' | Yes — nearest boundary shows its fallback | Only if no nearer error.tsx |
Route Handler (route.ts) | Yes — routeType: 'route' | No — the client gets a 500 response; no React tree is involved | No |
| Server Action | Yes — routeType: 'action' | Yes — bubbles to the nearest boundary | Only if no nearer error.tsx |
| Middleware / proxy | Yes — routeType: 'proxy' (see the caveat below) | No | No |
| Root layout render | Yes | No — error.tsx doesn't wrap the layout above it | Yes — it replaces the root layout |
| Client Component render, in the browser | No | Yes | Yes, as a last resort |
| Client event handlers / async code | No | No — boundaries only catch render errors | No |
The last row is the one that surprises people. Error boundaries — error.tsx included — only catch errors thrown during render; the Next.js error-handling guide is explicit that event handlers and async code run after rendering, where no boundary can reach them. None of that reaches onRequestError either, because it never touched the server. We wrote up the mechanics and the fixes in why React error boundaries don't catch async errors.
The digest is the join key
In production, Next.js redacts server error messages before forwarding them to the client — your error.tsx receives a generic message plus an error.digest hash instead of the real thing, so sensitive details never leak into the browser. Per the error.js reference, the digest exists precisely "to match the corresponding error in server-side logs."
That cuts both ways:
- Rendering
error.tsxdoesn't mean the error was handled. A Server Component that throws still firesonRequestErroron the server, even though the user saw a polite fallback. Expect both signals for the same failure. - Report the digest from both sides and you can correlate them. The server report carries the real message and stack; the client report tells you a human actually saw the failure. Dedupe on digest so one crash doesn't count twice.
// app/dashboard/error.tsx
'use client' // error boundaries must be Client Components
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// The message is redacted in production — the digest is what
// links this to the full server report from onRequestError.
console.error('boundary rendered, digest:', error.digest)
}, [error])
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
One non-event worth knowing: redirect() and notFound() also work by throwing, but they're control flow, not failures — they resolve to a redirect or a 404, not an error report. Your feed stays clean.
What are the onRequestError runtime gotchas?
Four things to know before you trust it in production: it runs on two runtimes, you must await your reporting, dev behaves differently from prod, and proxy.ts currently has an open bug.
It runs in both Node.js and Edge
Next.js bundles instrumentation.ts for whichever runtimes your routes use. If any route opts into the Edge runtime, your onRequestError must survive there too — which rules out Node-only APIs like fs and most heavyweight SDK clients. Plain fetch works everywhere. If you genuinely need different behavior per runtime, branch on process.env.NEXT_RUNTIME:
// instrumentation.ts
import type { Instrumentation } from 'next'
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context
) => {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { reportError } = await import('./error-reporting.node')
await reportError(error, request, context)
} else {
const { reportError } = await import('./error-reporting.edge')
await reportError(error, request, context)
}
}
Await your reporting
The docs say it plainly: if you start async work in onRequestError, await it. The signature returns void | Promise<void> for a reason. On serverless platforms the host can freeze the instance as soon as the response is done — it suspends a fire-and-forget fetch mid-flight and your report silently never arrives. Make the function async and await every send before returning.
Verify in a production build, not next dev
In development, the error overlay intercepts failures for display and Fast Refresh keeps re-mounting trees, so what you observe in next dev isn't what production does. Don't trust it. When you wire up onRequestError, verify it against a real build: run next build && next start, curl a route that throws, and confirm the report lands. Thirty seconds of checking beats discovering in an incident that your error feed never connected.
The proxy.ts caveat
Next.js 16 renamed middleware.ts to proxy.ts, and context.routeType now reports 'proxy' where Next 15 said 'middleware'. But as of May 2026 there's an open bug: Next.js doesn't forward errors thrown in proxy.ts to onRequestError, even though it forwarded the same errors from a legacy middleware.ts. If your proxy logic can fail in ways you care about, keep an explicit try/catch inside it until the fix lands.
Wiring onRequestError to an error tracker
Here's a complete instrumentation.ts that ships structured reports to an error-tracking endpoint, with the two production concerns the minimal version skips: it doesn't forward sensitive headers, and the reporter itself can never throw.
// instrumentation.ts
import type { Instrumentation } from 'next'
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context
) => {
try {
await fetch('https://errors.example.com/ingest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.CATCH_ACCESS_TOKEN}`,
},
body: JSON.stringify({
name: error.name,
message: error.message,
stack: error.stack,
digest: error.digest,
request: {
path: request.path,
method: request.method,
// request.headers includes cookies and auth tokens —
// forward only what you'll actually use.
userAgent: request.headers['user-agent'],
},
context, // routerKind, routePath, routeType, renderSource, ...
runtime: process.env.NEXT_RUNTIME,
occurredAt: new Date().toISOString(),
}),
})
} catch {
// An error reporter must never become an error source.
}
}
Two notes on that payload. request.headers is the full header map — cookies, authorization tokens, all of it — so pick fields instead of forwarding it wholesale. And error.stack in a production build points at minified bundle code; to get readable frames back you need to upload source maps, which we covered in JavaScript source maps in production.
If you'd rather not hand-roll the ingest endpoint, grouping, and alerting behind that fetch: this hook is exactly how @catch.dev/next captures the server half, and the same package covers client errors too. npm install @catch.dev/next, set CATCH_ACCESS_TOKEN, and Catch groups errors from both sides into issues with stack traces and Slack alerts — the getting-started guide has the five-minute version, and the free tier covers hobby apps if you want to try it on a side project.
Don't forget the client half
onRequestError never sees the browser. A Client Component that crashes during render lands in error.tsx; a click handler that throws or a promise that rejects lands nowhere at all unless you've registered window.onerror and unhandledrejection listeners — error boundaries won't save you there, for reasons we unpack in our post on React error boundaries and async errors.
Full coverage for a Next.js app is three small pieces:
instrumentation.tswith an awaitedonRequestError— every uncaught server error, one function.error.tsxper segment (plusglobal-error.tsxfor the root layout) — fallback UI, digest logged.- A client-side reporter for render crashes, handlers, and rejections that boundaries miss.
None of it requires touching a single route. That's the appeal: one boring file at the root of the repo, and the days of silent 500s are over.