Next.js on Cloudflare Workers (OpenNext)

A working recipe for capturing both client and server errors from a Next.js app bundled into a Cloudflare Worker via @opennextjs/cloudflare.

If you deploy Next.js to Cloudflare Workers via @opennextjs/cloudflare, the official Sentry SDKs don't fit cleanly. This page documents the pattern we use in production for the GlitchReplay dashboard itself: direct envelope POSTs from a Next.js instrumentation.js hook, plus a top-level instrumentation-client.js for first-frame client errors.

Why not the standard SDKs?

  • @sentry/nextjs assumes Node primitives the Workers runtime doesn't expose, even with nodejs_compat, and trips a known crash on OpenNext (opennextjs-cloudflare#587).
  • @sentry/cloudflare works on bare Workers but has no captureRequestError export — it's designed to wrap the worker fetch handler with withSentry, and OpenNext gives you no clean hook to do that around its generated .open-next/worker.js.

Both SDKs' only job is to construct a Sentry envelope and POST it. On Workers it's simpler to do that ourselves — about 80 lines, no dependencies, no bundle-size hit.

1. Server-side error helper

Drop this into lib/glitchreplay-edge.js. It builds a minimal Sentry envelope and POSTs it. Edge-runtime safe — only fetch, crypto.randomUUID, and string ops:

// lib/glitchreplay-edge.js
const PROJECT_ID = '<YOUR_PROJECT_ID>';
const PUBLIC_KEY = '<YOUR_PUBLIC_KEY>';
const ENVELOPE_URL = `https://glitchreplay.com/api/${PROJECT_ID}/envelope/?sentry_key=${PUBLIC_KEY}`;

function buildEvent(error, context) {
  const err = error instanceof Error ? error : new Error(String(error));
  const stackFrames = (err.stack || '')
    .split('\n')
    .slice(1)
    .map(line => {
      const m = line.trim().match(/^at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)$/) ||
                line.trim().match(/^at\s+(.+?):(\d+):(\d+)$/);
      if (!m) return null;
      if (m.length === 5) {
        return { function: m[1], filename: m[2], lineno: Number(m[3]), colno: Number(m[4]), in_app: true };
      }
      return { function: '?', filename: m[1], lineno: Number(m[2]), colno: Number(m[3]), in_app: true };
    })
    .filter(Boolean)
    .reverse();

  return {
    event_id: crypto.randomUUID().replace(/-/g, ''),
    timestamp: Date.now() / 1000,
    platform: 'javascript',
    level: 'error',
    release: globalThis.process?.env?.NEXT_PUBLIC_RELEASE || 'dev',
    environment: globalThis.process?.env?.NODE_ENV || 'production',
    server_name: 'cloudflare-worker',
    tags: {
      runtime: 'cloudflare-workers',
      route_path: context?.routePath,
      route_type: context?.routeType,
      router_kind: context?.routerKind,
    },
    request: context?.request
      ? { url: context.request.path, method: context.request.method, headers: context.request.headers }
      : undefined,
    exception: {
      values: [{
        type: err.name || 'Error',
        value: err.message || String(err),
        stacktrace: stackFrames.length ? { frames: stackFrames } : undefined,
      }],
    },
  };
}

function buildEnvelope(event) {
  const header = JSON.stringify({
    event_id: event.event_id,
    sent_at: new Date().toISOString(),
    sdk: { name: 'glitchreplay.edge', version: '0.1.0' },
  });
  return `${header}\n${JSON.stringify({ type: 'event' })}\n${JSON.stringify(event)}`;
}

export function reportServerError(error, context = {}) {
  try {
    const promise = fetch(ENVELOPE_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-sentry-envelope' },
      body: buildEnvelope(buildEvent(error, context)),
    }).catch(() => {});
    if (typeof context.waitUntil === 'function') context.waitUntil(promise);
  } catch {
    // Reporter must never throw.
  }
}

2. Wire it into Next.js's instrumentation hook

Next.js 15+ calls onRequestError on every server-side error from route handlers, server actions, and RSC. OpenNext faithfully forwards this hook to your worker:

// instrumentation.js  (project root, NOT inside app/)
import { reportServerError } from '@/lib/glitchreplay-edge';

export function register() {
  // No SDK init needed — reportServerError is stateless.
}

export function onRequestError(error, request, context) {
  reportServerError(error, {
    request,
    routePath: context?.routePath,
    routeType: context?.routeType,
    routerKind: context?.routerKind,
  });
}

3. Client-side init

Next.js 15.3+ supports instrumentation-client.js at the project root. Putting Sentry.init there (top-level, no useEffect) makes it run before React hydration, so first-frame errors are captured. @sentry/react works fine in Workers — it's only the server SDKs that don't.

// instrumentation-client.js  (project root, NOT inside app/)
import * as Sentry from '@sentry/react';
import { axeIntegration } from '@glitchreplay/a11y';
import { networkProbeIntegration } from '@glitchreplay/network-probe';

const PROJECT_ID = '<YOUR_PROJECT_ID>';
const PUBLIC_KEY = '<YOUR_PUBLIC_KEY>';

if (typeof window !== 'undefined') {
  Sentry.init({
    dsn: `https://${PUBLIC_KEY}@glitchreplay.com/0`,
    release: process.env.NEXT_PUBLIC_RELEASE || 'dev',
    tracesSampleRate: 0,
    replaysSessionSampleRate: 1.0,
    replaysOnErrorSampleRate: 1.0,
    integrations: [
      Sentry.replayIntegration({
        maskAllText: false,
        maskAllInputs: true,
        blockAllMedia: true,
      }),
      axeIntegration({
        runOn: ['development', 'staging', 'production'],
        productionSampleRate: 0.05,
        wcagLevel: 'AA',
        minImpact: 'moderate',
      }),
      networkProbeIntegration({
        verifyResourceErrors: [/imagedelivery\.net/i],
      }),
    ],
  });

  fetch(`https://glitchreplay.com/api/${PROJECT_ID}/config?sentry_key=${PUBLIC_KEY}`, {
    cache: 'no-store',
  })
    .then((r) => (r.ok ? r.json() : null))
    .then((cfg) => {
      const replay = Sentry.getReplay();
      if (!replay) return;
      if (!cfg?.replay?.enabled) {
        replay.stop();
        return;
      }
      const sessionRate = Number(cfg.replay.sessionSampleRate ?? 1);
      if (sessionRate < 1 && Math.random() >= sessionRate) replay.stop();
    })
    .catch(() => {});
}

Remove any prior useEffect-based Sentry.init component (and the layout that mounts it) — this file replaces it.

4. Tag every build with a release

Two pieces have to agree on the release identifier: the release in Sentry.init on the client, and the --release flag passed to the source-map uploader. If they don't match, GlitchReplay can't map a stack frame to its source. The simplest fix is a tiny wrapper that resolves a release once and feeds it to both:

// scripts/build-with-release.mjs
import { execSync, spawnSync } from 'node:child_process';
import process from 'node:process';

function resolveRelease() {
  const explicit =
    process.env.GLITCHREPLAY_RELEASE ||
    process.env.WORKERS_CI_COMMIT_SHA ||
    process.env.CF_PAGES_COMMIT_SHA ||
    process.env.GITHUB_SHA;
  if (explicit) return explicit.slice(0, 7);
  try {
    return execSync('git rev-parse --short HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
      .toString().trim();
  } catch {
    return 'dev';
  }
}

const release = resolveRelease();
const env = { ...process.env, NEXT_PUBLIC_RELEASE: release };

if (spawnSync('opennextjs-cloudflare', ['build'], { stdio: 'inherit', env }).status !== 0) {
  process.exit(1);
}

spawnSync('node', [
  'scripts/upload-sourcemaps.mjs',
  '--dsn', '<YOUR_DSN>',
  '--dir', '.open-next/assets/_next/static/chunks',
  '--release', release,
  '--best-effort',
], { stdio: 'inherit', env });

Then in package.json:

"scripts": {
  "build": "node scripts/build-with-release.mjs"
}

Because NEXT_PUBLIC_RELEASE is set when next build runs, the client bundle bakes that exact identifier into process.env.NEXT_PUBLIC_RELEASE, and the source maps you upload moments later carry the same tag.

Source-map upload itself is unchanged — see Source maps.

What this captures

  • Server-side: any thrown error from a Next.js route handler, server component, server action, or middleware — with stack traces and the route's path/type.
  • Client-side: window errors, unhandled promise rejections, React render errors (when wrapped in Sentry.ErrorBoundary), and the last 30s of session replay.
  • Release tagging: every event ships with the build's git SHA, matching the uploaded source maps.

Verifying it works

  1. Throw a deliberate error from a route handler: app/api/_test/error/route.js throw new Error('test from worker').
  2. Hit the route in production, then check your project's issue feed.
  3. Confirm the release tag on the event matches the git SHA you just deployed and that the stack trace shows your source paths (not minified bundle paths). If it shows minified paths, the source maps for that release didn't make it — re-run the build, or check the upload script's output.

Caveats

  • The minimal helper does not implement breadcrumbs, session tracking, or transactions. It captures errors and that's it. If you need breadcrumbs on the server too, you'll have to write them yourself or wait for one of the official SDKs to support OpenNext.
  • onRequestError fires after Next.js has already serialized its 500 response, so reports are sent fire-and-forget. If you're running in a context where the worker may shut down immediately (e.g. queue consumers), pass ctx.waitUntil through so the POST survives.