Next.js error monitoring (App Router + edge runtime).
Next.js has three runtimes in a single project: the browser, Node.js on the server, and the V8 edge runtime for middleware and edge functions. Getting error monitoring right means initializing the SDK correctly for all three, wiring up source maps so production traces are readable, and knowing which parts of the Sentry setup documentation apply to Pages Router only. This guide covers all of it, and ends with pointing the DSN at urgentry instead of Sentry.
20 seconds. Install @sentry/nextjs. Create three config files: sentry.client.config.ts, sentry.server.config.ts, sentry.edge.config.ts. Add withSentryConfig to next.config.mjs. Add instrumentation.ts at the project root with a register() function that imports the right config per runtime. Add error.tsx and global-error.tsx for client-side boundary capture. Set SENTRY_DSN to your urgentry DSN. Done.
60 seconds. The fiddly part is not the SDK — it’s the surface area. App Router splits request handling across server components (RSC), client components, route handlers, server actions, middleware (edge), and layout files. Each of these has different error propagation behavior. Server components throw to the nearest error.tsx; server actions need explicit try/catch wrappers with Sentry.captureException; middleware runs in the edge runtime which lacks Node APIs. The SDK tries to paper over all of this, but you have to give it the right entry points to work from.
The whole point of urgentry is that none of this SDK setup changes. You get the same @sentry/nextjs configuration you would use for Sentry, and you swap one environment variable. Source map upload, session tracking, performance tracing: all of it targets your urgentry instance instead of sentry.io.
Why Next.js error tracking is fiddly
The typical “add Sentry to your app” tutorial assumes one runtime, one init call, one bundle. Next.js is three separate runtimes compiled from the same source tree.
The browser gets a client bundle. The server gets a Node.js process running route handlers, server components, and server actions. Middleware runs in a V8 isolate — the edge runtime — which deliberately strips out Node’s built-in modules to keep cold starts below a millisecond. Each of these needs its own SDK initialization, its own DSN reference, and its own error boundary strategy.
The App Router made this harder, not easier. The Pages Router had a single server entry point. The App Router distributes server logic across React Server Components, layouts, and server actions that are called as RPC stubs from the client. RSC errors propagate to the nearest error.tsx boundary; server action errors propagate back to the caller but do not trigger the same boundary unless the component re-throws. Middleware errors land in a place where very little of the normal error pipeline applies.
On top of that: Next.js build output is heavily transformed. The production bundle goes through SWC, then minification, then route splitting. Without source map upload at build time, every error in production will show a minified stack — e.js:1:3420 instead of the actual file and function name. This is the most common complaint about production Next.js error monitoring, and it is entirely avoidable with one build step.
None of this is the fault of the SDK. The SDK is doing the right thing. The configuration burden is real, though, and this guide exists to remove it.
Installing @sentry/nextjs
The SDK ships a wizard that handles most of the scaffolding. Run it once, review what it generated, then adjust for the parts it gets wrong.
npx @sentry/wizard@latest -i nextjs
The wizard will ask for a DSN and an auth token. Give it placeholder values for now — you will replace them with your urgentry values before deploying. What matters is that the wizard generates the three config files and patches next.config.mjs. After it runs, you should have:
sentry.client.config.tssentry.server.config.tssentry.edge.config.tssrc/instrumentation.ts(orinstrumentation.tsat root, depending on your project layout)- A modified
next.config.mjsthat wraps your config inwithSentryConfig
If the wizard does not generate the edge config file, create it manually — it is not optional if you use middleware.
Do not use npm install @sentry/nextjs alone and then copy-paste the init call into layout.tsx. That is the Pages Router pattern. In App Router, calling Sentry.init() inside a component file creates multiple init calls per deployment and breaks session tracking. The instrumentation hook is the right place.
The three config files
Each config file targets one runtime. They look similar but serve different purposes.
sentry.client.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
// Capture 10% of traces in production; 100% in development
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Replay captures 10% of sessions, 100% of sessions with errors
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration(),
],
// The tunnel route below avoids ad-blocker DSN blocking
tunnel: "/api/sentry-tunnel",
});
Two things worth explaining here. First, the NEXT_PUBLIC_ prefix: client-side code runs in the browser, so the DSN must be prefixed with NEXT_PUBLIC_ to survive the build step. An unprefixed SENTRY_DSN variable will be undefined in the client bundle. Second, the tunnel option: this routes Sentry events through a Next.js API route on your own domain, bypassing the ad-blockers and browser extensions that block ingest.sentry.io directly. The tunnel route is covered below.
sentry.server.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
// Spotlight is the local Sentry-compatible dev UI; disable in production
spotlight: process.env.NODE_ENV === "development",
});
The server config uses SENTRY_DSN without the NEXT_PUBLIC_ prefix because this code never ships to the browser. Keep the two environment variables in sync — or use one variable name and assign it to both in your CI config.
sentry.edge.config.ts
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.SENTRY_DSN,
// Keep tracing light in the edge runtime — cold start matters here
tracesSampleRate: 0.05,
});
The edge config is intentionally minimal. The edge runtime does not have access to fs, net, crypto (Node’s version), or most other built-ins. The SDK’s edge build avoids those modules, but any integration you add must also be edge-compatible. Session replay, profiling, and most performance integrations are Node-only. Do not paste the server config here and call it done.
next.config.mjs setup
The withSentryConfig wrapper handles source map upload during builds and injects the SDK initialization into the webpack build graph.
import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your existing Next.js config
experimental: {
instrumentationHook: true, // Required for Next.js < 14.2 to use instrumentation.ts
},
};
export default withSentryConfig(nextConfig, {
// Your urgentry project slug and organization slug
org: "your-org-slug",
project: "your-project-slug",
// Silent during CI to avoid noise in build logs
silent: !process.env.CI,
// Upload source maps; requires SENTRY_AUTH_TOKEN at build time
widenClientFileUpload: true,
// Hide source map files from the public-facing build output
hideSourceMaps: true,
// Disable the automatic instrumentation of Vercel cron monitors
// if you are not using Vercel
disableLogger: true,
// Tunnel path for ad-blocker bypass (matches tunnel option in client config)
tunnelRoute: "/api/sentry-tunnel",
});
The instrumentationHook: true flag is required in Next.js versions before 14.2. In 14.2 and later, instrumentation.ts is enabled by default and the flag is a no-op. It is safe to leave it in place either way.
Source map upload at build time
Source map upload is the step most teams skip and then regret in production. The build plugin handles it automatically when the auth token is present.
Set three environment variables in your CI environment:
SENTRY_AUTH_TOKEN=your_urgentry_auth_token
SENTRY_URL=https://errors.example.com # your urgentry base URL
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=your-project-slug
With these set, every next build run uploads source maps to your urgentry instance immediately after compilation. urgentry uses the same artifact bundle format and upload endpoint as Sentry, so the build plugin needs no other changes.
A common mistake is setting hideSourceMaps: false in withSentryConfig to “make source maps available.” This makes the maps publicly accessible in the deployed bundle — which means any user can read your original source — without giving you anything you could not get from the uploaded artifacts. Keep hideSourceMaps: true and rely on the uploaded artifacts.
Another common mistake: running next build locally without the auth token set, then deploying the output. The maps were generated locally but never uploaded. Production gets the minified bundle with no corresponding artifacts. The fix is to make source map upload part of CI, not local builds.
instrumentation.ts (Next 13.2+)
The instrumentation.ts file lives at the root of your project (or in the src/ directory if your project uses that layout). Next.js calls its exported register() function exactly once when the server process starts, before handling any request.
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("./sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config");
}
}
The NEXT_RUNTIME environment variable distinguishes the two server-side runtimes at build time. The dynamic import ensures that the wrong config file is never bundled into the wrong runtime. If you use a static import at the top of instrumentation.ts, Next.js will include both configs in both bundles, and the edge bundle will fail at deployment because it will try to import Node modules.
The client config is not imported here. The browser bundle picks up sentry.client.config.ts through the webpack plugin, which injects it as a module-level side effect. You do not wire that one up manually.
Error boundaries: error.tsx and global-error.tsx
App Router uses React’s error boundary mechanism for client-side error handling. Any error.tsx file in a route segment becomes the error boundary for that segment. global-error.tsx at the root is the fallback for errors that escape all segment boundaries.
// app/error.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<div>
<h2>Something went wrong.</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/global-error.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
<h2>Something went wrong.</h2>
<button onClick={reset}>Try again</button>
</body>
</html>
);
}
Both files must be "use client" directives. Error boundaries in React are a client-side concept — they exist to catch errors during client rendering, not during server-side rendering. A server component that throws will propagate to the nearest error.tsx only during client navigation after hydration. During SSR, the server error is caught by the framework and surfaces to the global-error.tsx boundary on the next client render.
The error.digest field is a server-generated hash that matches the error to a server-side log entry. Capture it alongside the exception so you can correlate client-reported errors with server logs:
Sentry.captureException(error, {
extra: { digest: error.digest },
});
One pitfall: Next.js hydration errors — mismatches between server-rendered HTML and the first client render — do not trigger error boundaries. They throw into the React reconciler and surface as console warnings or as React 18’s hydration error overlay in development. The SDK may capture them through its onUnhandledRejection handler, but the stack trace is almost always useless because the mismatch location is inside React internals, not your component. If you care about hydration errors in production, add a custom event listener for the React 18 recoverable error hook instead.
Server actions: capturing errors
Server actions are async functions defined with the "use server" directive. They run on the server but are called from client components as if they were regular functions. When a server action throws, the error is serialized and sent to the client, but it does not trigger an error boundary — the calling component receives a rejected Promise and must handle it explicitly.
This means the SDK’s automatic error capture does not cover server action failures by default. You need explicit wrappers.
// app/actions/create-post.ts
"use server";
import * as Sentry from "@sentry/nextjs";
export async function createPost(formData: FormData) {
return await Sentry.withServerActionInstrumentation(
"createPost",
{
formData,
headers: headers(),
recordResponse: true,
},
async () => {
// Your action logic here
const title = formData.get("title") as string;
if (!title) {
throw new Error("Title is required");
}
// ... database write, etc.
return { success: true };
}
);
}
The withServerActionInstrumentation wrapper captures both thrown exceptions and the performance span for the action. The recordResponse: true option attaches the return value to the span — useful for debugging actions that return error objects instead of throwing.
If you do not want to use the wrapper (it is verbose for simple actions), the minimum is a try/catch with explicit capture:
"use server";
import * as Sentry from "@sentry/nextjs";
export async function deletePost(id: string) {
try {
await db.post.delete({ where: { id } });
} catch (error) {
Sentry.captureException(error, {
tags: { action: "deletePost", postId: id },
});
throw error; // re-throw so the client knows the action failed
}
}
Re-throwing after capture is important. If you swallow the error, the client component will receive a resolved Promise and may show a success state for a failed operation.
Edge runtime: what changes
The edge runtime is a V8 isolate. It is not Node.js. The following are unavailable: fs, net, dns, path, child_process, worker_threads, and most other Node built-ins. The Sentry SDK ships a separate edge build that avoids all of these. The consequence is a different set of capabilities.
What works in the edge runtime:
- Basic error capture with
Sentry.captureException - Breadcrumbs and context
- Performance spans (via
Sentry.startSpan) - User context
- Custom tags
What does not work:
- Session replay (requires browser DOM APIs on the client side, and is irrelevant server-side)
- Profiling (requires Node’s
perf_hooks) - Some automatic integrations that patch Node modules
- Deep stack traces — the V8 isolate caps the call stack depth it exposes
The stack depth limit is the one that surprises people most. An error thrown in middleware will produce a shorter stack trace than the same error thrown in a Node.js route handler. This is not a bug in the SDK or in urgentry. It is a property of the isolate. The frames that matter — your application code — are usually present. The ones that are truncated are typically internal Next.js or React frames.
When setting up the edge config, resist the temptation to paste in integrations from the server config. Any integration that imports a Node module will cause the edge bundle to fail at build time or at runtime with an obscure “module not found” error. Keep the edge config to the minimum shown earlier.
Middleware error capture
Next.js middleware runs in the edge runtime before any route is matched. It is the right place for auth checks, redirects, and header manipulation. It is also the place where errors are hardest to observe — middleware failures produce a 500 response with no request body, and the error is typically only visible in the deployment platform’s function logs.
The SDK’s instrumentation wraps middleware automatically when withSentryConfig is in place. But if you want explicit capture with additional context, wrap the middleware handler:
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import * as Sentry from "@sentry/nextjs";
export async function middleware(request: NextRequest) {
return await Sentry.wrapMiddlewareWithSentry(handleMiddleware)(request);
}
async function handleMiddleware(request: NextRequest): Promise<NextResponse> {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Validate token — this can throw
await validateSessionToken(token);
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
};
The wrapMiddlewareWithSentry helper adds the middleware to the active trace, captures any thrown exception before it reaches the framework, and flushes the Sentry queue before the isolate terminates. That last point matters: edge isolates can terminate without draining the SDK’s event buffer if you do not flush explicitly. The wrapper handles this for you.
Without the wrapper, a middleware error may reach urgentry only if the SDK’s beforeExit handler fires, which is not guaranteed in edge runtimes. Use the wrapper.
DSN tunneling for ad-blocker bypass
Browser extensions that block tracking scripts frequently block requests to ingest.sentry.io by hostname. The tunnel option in the client config routes Sentry envelopes through an API route on your own domain, which those extensions do not block.
// app/api/sentry-tunnel/route.ts
import { NextRequest, NextResponse } from "next/server";
const SENTRY_HOST = new URL(
process.env.SENTRY_DSN ?? ""
).hostname; // e.g., "errors.example.com" for urgentry
const SENTRY_PROJECT_IDS = [
process.env.SENTRY_PROJECT_ID ?? "",
];
export async function POST(request: NextRequest) {
try {
const envelope = await request.text();
const header = envelope.split("\n")[0];
const { dsn } = JSON.parse(header) as { dsn: string };
const url = new URL(dsn);
if (url.hostname !== SENTRY_HOST) {
return NextResponse.json(
{ error: "Invalid DSN hostname" },
{ status: 400 }
);
}
const projectId = url.pathname.replace("/", "");
if (!SENTRY_PROJECT_IDS.includes(projectId)) {
return NextResponse.json(
{ error: "Invalid project ID" },
{ status: 403 }
);
}
const upstream = `${url.protocol}//${url.hostname}/api/${projectId}/envelope/`;
const response = await fetch(upstream, {
method: "POST",
headers: { "Content-Type": "application/x-sentry-envelope" },
body: envelope,
});
return new NextResponse(response.body, {
status: response.status,
});
} catch (error) {
return NextResponse.json({ error: "Tunnel error" }, { status: 500 });
}
}
When you point the DSN at urgentry, the tunnel route forwards envelopes to your urgentry instance’s envelope endpoint instead of sentry.io. The validation logic in the route ensures that only envelopes from your own DSN are forwarded. Do not skip the hostname check — without it, the tunnel becomes an open proxy.
Performance and tracing setup
The SDK instruments fetch and most HTTP calls automatically when initialized. For App Router, it also instruments route handlers and server components through the webpack plugin. Enabling distributed tracing requires two additional pieces:
// sentry.server.config.ts — add to the existing init call
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
integrations: [
// Instrument Node.js HTTP module for outgoing requests
Sentry.httpIntegration({ tracing: true }),
],
});
// sentry.client.config.ts — add browserTracingIntegration
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.1,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
});
For database tracing, add the relevant integration for your ORM. Prisma has first-class SDK support; Drizzle does not yet, but you can wrap queries with Sentry.startSpan manually.
// Manual span example for Drizzle
const result = await Sentry.startSpan(
{
name: "db.query.getUser",
op: "db",
attributes: { "db.system": "sqlite" },
},
async () => {
return db.select().from(users).where(eq(users.id, userId));
}
);
Keep tracesSampleRate below 1.0 in production. At 100% sampling, a busy Next.js app can generate 10x the event volume of its errors alone. Start at 0.05 or 0.1, watch the ingest rate, and raise it when you have a specific performance question to answer.
Pointing at urgentry instead of Sentry
This is the part that does not require any of the configuration above to change. The DSN is the only variable.
In urgentry, create a project and copy its DSN. It will look like:
https://<public_key>@errors.example.com/<project_id>
Set the following environment variables in your deployment:
# Runtime — used by the server and edge SDK init
SENTRY_DSN=https://<public_key>@errors.example.com/<project_id>
# Build time — used by the @sentry/nextjs webpack plugin for source map upload
SENTRY_URL=https://errors.example.com
SENTRY_AUTH_TOKEN=<urgentry_auth_token>
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=your-project-slug
# Client-side — prefixed so Next.js includes it in the browser bundle
NEXT_PUBLIC_SENTRY_DSN=https://<public_key>@errors.example.com/<project_id>
The SENTRY_URL variable tells the webpack plugin where to upload source maps. By default, it points to https://sentry.io. Overriding it with your urgentry base URL redirects all artifact uploads to your instance.
Nothing else changes. The @sentry/nextjs SDK speaks the Sentry envelope protocol. urgentry implements that protocol. The events arrive, the source maps resolve, and the traces show up in the urgentry UI with the same structure they would in Sentry.
If you are running urgentry behind a Caddy or nginx reverse proxy, confirm that the /api/<project_id>/envelope/ path is not being stripped or rewritten. That endpoint is where the SDK sends all events. The store endpoint at /api/<project_id>/store/ is the legacy fallback; urgentry supports both.
Common pitfalls
Missing source maps in production
This is the most reported issue with Next.js + Sentry, by a significant margin. The symptoms: stack traces in production show minified names. The diagnosis is almost always one of three things:
SENTRY_AUTH_TOKENis not set in CI. The build plugin runs but silently skips upload. Add the token to your CI secrets and confirm the build log shows “Uploading source maps.”SENTRY_URLstill points to sentry.io. If you migrated to urgentry but only changed the runtime DSN, source maps are being uploaded to sentry.io while events go to urgentry. SetSENTRY_URLto your urgentry base URL.- The build output directory is wrong.
withSentryConfiglooks for source maps in.next/. If you have a customdistDirinnext.config.mjs, add it to the Sentry config as well.
Double init
If you call Sentry.init() in both instrumentation.ts and in a layout or component file, the SDK initializes twice. The second init call resets the scope and may overwrite integrations configured in the first. The symptom is events that appear with empty or incomplete context.
The fix: initialize the server SDK exclusively in instrumentation.ts. Do not import either server config file in components, layouts, or route handlers. The SDK is a singleton; init once.
Hydration mismatches eating the trace
React hydration errors do not propagate through the normal error boundary path. They are caught by React’s own reconciler and either recovered silently or surfaced as a console warning. The SDK’s automatic capture may see them through the onerror and onunhandledrejection handlers, but the resulting event usually has no meaningful stack trace because the throw originated inside React internals.
If you are seeing events in urgentry with no stack and a message that looks like “Hydration failed because...”, the right response is not to suppress them — it is to fix the hydration mismatch. Common causes: server-rendered timestamps that differ from client-rendered ones (wrap in a Suspense boundary or use suppressHydrationWarning on the element), and third-party scripts that modify the DOM before React hydrates.
Edge middleware capturing nothing
If errors in middleware never appear in urgentry, the most likely cause is that the SDK’s event buffer is being discarded when the edge isolate terminates. Either use wrapMiddlewareWithSentry (which flushes before returning) or call await Sentry.flush(2000) explicitly at the end of your middleware function before returning the response.
Server actions errors not grouped correctly
Server actions are compiled to unique route IDs by Next.js. Without source maps, the error grouping fingerprint will be based on the compiled route ID rather than the action name and call site. This produces dozens of distinct groups for what is actually one error. The fix is source map upload at build time — with maps in place, urgentry resolves the fingerprint to the original source location.
tracesSampleRate at 1.0 in production
A common copy-paste from the docs that sets tracesSampleRate: 1.0 in production. At full sampling, every page load generates at least one performance transaction. On a busy site, this can multiply your event volume by an order of magnitude. The ingest cost on urgentry is negligible for a self-hosted instance, but the storage cost compounds: performance transactions are large events and they accumulate fast. Start at 0.05 and tune from there.
FAQ
Does @sentry/nextjs work with the App Router?
Yes. As of @sentry/nextjs 8.x the SDK ships three separate entry points: sentry.client.config.ts, sentry.server.config.ts, and sentry.edge.config.ts. Each one targets a different runtime. App Router server components and route handlers are covered by the server entry point; edge middleware is covered by the edge entry point.
What is instrumentation.ts and why does it matter?
Next.js 13.2+ includes a first-party server-side lifecycle hook at src/instrumentation.ts (or instrumentation.ts in the root). The register() function it exports runs once when the server process starts, before any request is handled. It is the correct place to call Sentry.init() for the Node.js runtime in App Router projects — not inside layout.tsx or any component file.
Why do source maps disappear in production?
The most common cause is that SENTRY_AUTH_TOKEN is missing at build time. Without the token, the build plugin skips upload. A secondary cause is SENTRY_URL still pointing to sentry.io after migrating to urgentry — source maps are uploaded to the wrong destination. Check both variables in your CI environment.
What does the edge runtime change about error capture?
The edge runtime is a V8 isolate without Node.js built-ins. The SDK’s edge build avoids Node APIs, but the consequence is shallower stack traces and no profiling support. Basic error capture, breadcrumbs, and performance spans all work. The stack depth is smaller because the isolate unwinds less than a full Node.js process.
Can I use urgentry without changing any SDK code?
Yes. Set SENTRY_DSN and NEXT_PUBLIC_SENTRY_DSN to your urgentry DSN. Set SENTRY_URL to your urgentry base URL for source map upload. Nothing else changes. urgentry implements the same Sentry envelope ingest and store endpoints that @sentry/nextjs targets.
Sources
- @sentry/nextjs SDK documentation — the canonical reference for installation, configuration, and the three config files.
- Next.js error handling documentation — App Router error boundaries,
error.tsx, andglobal-error.tsx. - Next.js instrumentation documentation — the
instrumentation.tslifecycle hook and theNEXT_RUNTIMEvariable. - Next.js route handlers documentation — server-side route handler patterns referenced in the middleware section.
- urgentry SDK and source map documentation — the
SENTRY_URLandSENTRY_AUTH_TOKENenvironment variables for self-hosted instances. - urgentry SDK ingest documentation — envelope and store endpoint definitions for urgentry-hosted projects.
Ready to point your Next.js app at urgentry?
Every configuration step in this guide works unchanged. The only thing that moves is the DSN. Download urgentry, create a project, copy the DSN, and your first Next.js error will land in a UI you control on hardware you own.