The problem
Sentry's Next.js SDK initializes in three places: instrumentation-client.ts (browser), sentry.server.config.ts (Node), and sentry.edge.config.ts (edge runtime). Each one calls Sentry.init() at module scope, and when the DSN environment variable is missing, the SDK throws and spews errors into the console on every request.
That's fine in production, where the DSN is always set. On local dev, a fresh clone, or a CI job that doesn't need error monitoring, it just makes the terminal unreadable.
One boolean, three files
The fix is a single exported constant in the shared config:
// lib/sentry.config.ts
export const sentryEnabled = !!publicEnv.NEXT_PUBLIC_SENTRY_CONFIG_ID;Each initialization file wraps its Sentry.init() call in the guard:
// sentry.server.config.ts
import { sentryEnabled, sharedSentryConfig } from '@/lib/sentry.config';
if (sentryEnabled) {
Sentry.init({ ...sharedSentryConfig });
}The edge config is identical. The client config follows the same pattern, just with a bit more work inside the guard: it defers integration loading via requestIdleCallback so it doesn't block LCP.
The router transition export
Next.js 16 expects instrumentation-client.ts to export onRouterTransitionStart for Sentry's router instrumentation. When Sentry's off, that export should be undefined so Next.js skips it:
export const onRouterTransitionStart = sentryEnabled
? Sentry.captureRouterTransitionStart
: undefined;That keeps Next.js from calling Sentry.captureRouterTransitionStart when the SDK was never initialized, which would throw at runtime.
The takeaway
Any SDK that initializes at module scope should be guarded behind the presence of its config. One boolean in a shared config file is a lot cheaper than an evening spent figuring out why local dev is spewing errors, and it keeps the "works on a fresh clone" promise honest.
