Userz

Next.js server errors

Server-side errors — Server Components, Route Handlers, Server Actions — reported from instrumentation.ts, without an adapter package.

Scope. The browser SDK captures errors in the browser. Errors thrown on the server during rendering or in a Route Handler never reach a browser as an exception, so Next exposes them through one hook, onRequestError in instrumentation.ts. This page wires that hook to Userz with the server-side API client. There is no adapter package for this, and none is needed.

Install

Bash
pnpm add @userz-ai/api

@userz-ai/api is the typed server-side client for the Userz REST API. It authenticates with an sk_ key from the dashboard, which is why this runs on the server and nowhere else.

The recipe

Create instrumentation.ts at the project root (or under src/). Export onRequestError; guard on the Node runtime so the Edge runtime, which has no key, does nothing.

TypeScript
// instrumentation.ts
import type { Instrumentation } from 'next';

export const onRequestError: Instrumentation.onRequestError = async (err, request, context) => {
  if (process.env.NEXT_RUNTIME !== 'nodejs') return;

  const { createUserzApi } = await import('@userz-ai/api');
  const userz = createUserzApi({ apiKey: process.env.USERZ_API_KEY! }); // sk_..., server-side only

  const error = err instanceof Error ? err : new Error(String(err));
  // Next may replace the thrown error during Server Components rendering; `digest` survives and
  // identifies the underlying error, so it is the idempotency key: one Signal per distinct error.
  const digest = 'digest' in error && typeof error.digest === 'string' ? error.digest : undefined;

  await userz.signals.create({
    appId: process.env.USERZ_APP_ID!,
    type: 'bug',
    severity: context.routeType === 'render' ? 'high' : 'medium',
    text: `${error.name}: ${error.message}\n\n${context.routeType} ${request.method} ${request.path}\nroute ${context.routePath} (${context.routerKind})`,
    sourceUrl: absoluteUrl(request.path),
    sourceExternalId: digest ? `next-server-error:${digest}` : undefined,
    sourceMeta: {
      digest,
      method: request.method,
      path: request.path,
      routerKind: context.routerKind,
      routePath: context.routePath,
      routeType: context.routeType,
      renderSource: context.renderSource,
      revalidateReason: context.revalidateReason,
      renderType: context.renderType,
      stack: error.stack,
    },
  });
};

function absoluteUrl(path: string): string | undefined {
  const origin = process.env.NEXT_PUBLIC_SITE_URL;
  return origin ? new URL(path, origin).toString() : undefined;
}

Two environment variables, both server-side: USERZ_API_KEY (an sk_ key) and USERZ_APP_ID (the App the Signals belong to, from GET /v1/apps). Neither carries a NEXT_PUBLIC_ prefix, so neither reaches a browser bundle.

What each field does

NameTypeDescription
sourceExternalIdnext-server-error:<digest>The idempotency key. A second report with the same key resolves to the existing Signal instead of creating another — a route that throws on every request produces one Signal, not one per request. Errors without a digest opt out of dedup.
textstringWhat the reviewer reads first: the error, then the request and route. Keep it to the facts; the structured copy is in sourceMeta.
sourceMetaRecord<string, unknown>onRequestError's whole context — router, route file, phase (render / route / action / proxy), render source, revalidation reason — plus the stack. Attached to the Signal for the agent that works it.
severity'low' | 'medium' | 'high'Your call. The recipe marks render failures high (a user saw an error page) and everything else medium.
sourceUrlstring (absolute)The page the error happened on, if you can name your origin. Optional; the request path is in sourceMeta regardless.

Behaviour to know

Request headers are not forwarded

request.headers carries cookies and authorization. The recipe does not copy them into the Signal, and you should not: a Signal is read by people and by an agent, and neither needs a session cookie.

Rate limits and failures

POST /v1/signals is rate-limited per key (a burst of about sixty, one per second sustained). A throttled or failed report throws from signals.create; Next logs a rejected onRequestError promise and continues serving. If you would rather never throw from the hook, wrap the call in a try/catch and log locally.

Development

The hook fires in next dev too. Point USERZ_APP_ID at a development App, or return early when process.env.NODE_ENV !== 'production'.

What about @userz-ai/node?

@userz-ai/node mints private-mode user tokens and verifies outbound webhook signatures. It has no error intake, and this recipe does not use it. Browser-side errors are the browser SDK's, captured automatically once telemetry is enabled.