Next.js (OTel)
Quick Start

Next.js

Traceway instruments Next.js on two sides:

  • Frontend (Browser) — uncaught errors, render errors, and session recording via @tracewayapp/react (opens in a new tab).
  • Backend (Server) — request traces, database spans, and server-side exceptions via OpenTelemetry.

The two work side by side on the same project. Most apps will want both.


Quick Start

Minimum configuration to get errors flowing in. Drop these in, replace the connection string, and you're done.

Frontend — install @tracewayapp/react, wrap your app in TracewayProvider:

npm install @tracewayapp/react
// app/traceway-provider.tsx
"use client";
 
import { TracewayProvider } from "@tracewayapp/react";
 
export function TracewayClientProvider({ children }: { children: React.ReactNode }) {
  return (
    <TracewayProvider connectionString="your-token@https://traceway.example.com/api/report">
      {children}
    </TracewayProvider>
  );
}
// app/layout.tsx
import { TracewayClientProvider } from "./traceway-provider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <TracewayClientProvider>{children}</TracewayClientProvider>
      </body>
    </html>
  );
}

Backend — install the OTel Node SDK and add a single instrumentation.ts:

npm install @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http \
  @opentelemetry/api
// instrumentation.ts (project root)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { NodeSDK } = await import("@opentelemetry/sdk-node");
    const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
    const { getNodeAutoInstrumentations } = await import("@opentelemetry/auto-instrumentations-node");
    const { Resource } = await import("@opentelemetry/resources");
 
    new NodeSDK({
      resource: new Resource({ "service.name": "my-nextjs-app" }),
      traceExporter: new OTLPTraceExporter({
        url: "https://your-traceway-instance.com/api/otel/v1/traces",
        headers: { Authorization: "Bearer your-project-token" },
      }),
      instrumentations: [getNodeAutoInstrumentations()],
    }).start();
  }
}

Next.js < 15: also set experimental.instrumentationHook: true in next.config.js. Not needed for 15+.

That's the whole base setup. The rest of this page is for the cases where the defaults aren't enough.


Frontend (Browser)

Captures browser errors, render errors, and session recordings. Works with both App Router and Pages Router.

Pages Router setup

If you're still on pages/, mount the provider directly in _app.tsx:

// pages/_app.tsx
import type { AppProps } from "next/app";
import { TracewayProvider } from "@tracewayapp/react";
 
export default function App({ Component, pageProps }: AppProps) {
  return (
    <TracewayProvider connectionString="your-token@https://traceway.example.com/api/report">
      <Component {...pageProps} />
    </TracewayProvider>
  );
}

What you get by default

Defaults match the rest of the JS SDK family — no flags needed:

OptionDefaultPurpose
sessionRecordingtruerrweb recorder runs; segments ride along with each exception
recordAllSessionsfalseOnly exception-bound clips upload (no always-on recording)
captureLogstrueconsole.{log,info,warn,error} mirrored into the rolling buffer
captureNetworktruefetch and XHR calls recorded as network actions
captureNavigationtrueHistory API push/replace/pop recorded as navigation actions

The connectionString uses the standard {token}@{apiUrl} format from your Traceway project page.

TracewayProvider also acts as a React error boundary — render-time exceptions are captured and re-thrown so your app behaves exactly as without Traceway. No separate <TracewayErrorBoundary> is required.

Capture errors manually

Inside any client component, use the useTraceway hook:

"use client";
 
import { useTraceway } from "@tracewayapp/react";
 
export function CheckoutButton() {
  const { captureException } = useTraceway();
 
  async function handleClick() {
    try {
      await placeOrder();
    } catch (e) {
      captureException(e as Error);
    }
  }
 
  return <button onClick={handleClick}>Place order</button>;
}

Test the frontend integration

Drop a throw into any client component and reload:

"use client";
 
export function TestErrorButton() {
  return (
    <button onClick={() => { throw new Error("Test error from Next.js client"); }}>
      Send test error
    </button>
  );
}

The error should appear in your Traceway dashboard under Issues within a few seconds, with the session recording attached.


Backend (Server)

Captures server-side traces, database spans, and exceptions thrown in Server Components, route handlers, and getServerSideProps — none of which the frontend SDK can see.

Setup with Prisma + metrics

The Quick Start above is the minimum. If you use Prisma or want metric export too, the fuller instrumentation.ts looks like:

npm install @prisma/instrumentation @opentelemetry/exporter-metrics-otlp-http
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { NodeSDK } = await import("@opentelemetry/sdk-node");
    const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http");
    const { OTLPMetricExporter } = await import("@opentelemetry/exporter-metrics-otlp-http");
    const { PeriodicExportingMetricReader } = await import("@opentelemetry/sdk-metrics");
    const { getNodeAutoInstrumentations } = await import("@opentelemetry/auto-instrumentations-node");
    const { Resource } = await import("@opentelemetry/resources");
    const { PrismaInstrumentation } = await import("@prisma/instrumentation");
 
    new NodeSDK({
      resource: new Resource({
        "service.name": "my-nextjs-app",
        "service.version": "1.0.0",
      }),
      traceExporter: new OTLPTraceExporter({
        url: "https://your-traceway-instance.com/api/otel/v1/traces",
        headers: { Authorization: "Bearer your-project-token" },
      }),
      metricReader: new PeriodicExportingMetricReader({
        exporter: new OTLPMetricExporter({
          url: "https://your-traceway-instance.com/api/otel/v1/metrics",
          headers: { Authorization: "Bearer your-project-token" },
        }),
        exportIntervalMillis: 30_000,
      }),
      instrumentations: [
        getNodeAutoInstrumentations(),
        new PrismaInstrumentation(),
      ],
    }).start();
  }
}

The process.env.NEXT_RUNTIME === "nodejs" guard is required — Next.js runs instrumentation.ts in both the Node.js and Edge runtimes, but the OTel Node SDK only works in Node.js. Dynamic import() keeps OTel out of the Edge bundle.

Enabling the Instrumentation Hook (Next.js 13.4–14.x)

For Next.js versions before 15, enable the instrumentation hook in next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    instrumentationHook: true,
  },
};
 
module.exports = nextConfig;

Not needed for Next.js 15+, where the hook is stable by default.

Route Helper (Required)

Next.js (App Router) doesn't use Express or Fastify under the hood, so OTel auto-instrumentation can't set http.route automatically. Without it, Traceway falls back to the literal URL path — every /api/users/1, /api/users/2 becomes a separate endpoint instead of grouping under GET /api/users/[id].

Create a withRoute helper that wraps your API route handlers. It sets http.route for endpoint grouping and records exceptions as Issues:

// lib/with-route.ts
import { trace, SpanStatusCode } from "@opentelemetry/api";
 
type RouteHandler = (
  req: Request,
  context: { params: Promise<Record<string, string>> }
) => Response | Promise<Response>;
 
export function withRoute(route: string, handler: RouteHandler): RouteHandler {
  return async (req, context) => {
    const span = trace.getActiveSpan();
    if (span) {
      span.setAttribute("http.route", route);
    }
    try {
      return await handler(req, context);
    } catch (error) {
      if (span) {
        span.recordException(error as Error);
        span.setStatus({
          code: SpanStatusCode.ERROR,
          message: (error as Error).message,
        });
      }
      throw error;
    }
  };
}

Wrap every API route handler with withRoute, passing the route pattern that matches your file path:

// app/api/users/route.ts
import { withRoute } from "@/lib/with-route";
import { prisma } from "@/lib/db";
 
export const GET = withRoute("/api/users", async () => {
  const users = await prisma.user.findMany();
  return Response.json(users);
});
// app/api/users/[id]/route.ts
import { withRoute } from "@/lib/with-route";
import { prisma } from "@/lib/db";
 
export const GET = withRoute("/api/users/[id]", async (req, { params }) => {
  const { id } = await params;
  const user = await prisma.user.findUnique({ where: { id: parseInt(id) } });
  if (!user) {
    return Response.json({ error: "User not found" }, { status: 404 });
  }
  return Response.json(user);
});

What gets captured

With the setup above, OTel captures the following automatically:

LayerWhat's Captured
HTTP serverIncoming requests with method, status code, duration
withRoute helperhttp.route for endpoint grouping + exception recording
Prisma (@prisma/instrumentation)Query spans with operation name, model, duration
Database (pg, mysql2, mongodb)Query spans (CJS packages — auto-instrumented)
Cache (redis, ioredis)Cache operation spans (auto-instrumented)
Outgoing fetch()Outgoing HTTP request spans (auto via instrumentation-undici)

Root SERVER spans become Endpoints in Traceway (e.g., GET /api/users/[id]), child spans become Spans, and exception events become Issues.

Prisma auto-instrumentation

With @prisma/instrumentation registered, every Prisma query creates child spans — no manual wrapping needed:

const users = await prisma.user.findMany();
const user = await prisma.user.create({ data: { name, email } });

Resulting trace:

GET /api/users              ← Endpoint
  └─ prisma:client:query    ← Span (findMany on User)

Outgoing fetch() auto-instrumentation

Outgoing fetch() calls are traced by @opentelemetry/instrumentation-undici:

export const GET = withRoute("/api/external", async () => {
  const res = await fetch("https://api.example.com/data");
  const data = await res.json();
  return Response.json(data);
});

Resulting trace:

GET /api/external           ← Endpoint
  └─ GET                    ← Span (https://api.example.com/data)

Manual spans

For operations that aren't auto-instrumented (SQLite via better-sqlite3, Server Component rendering, custom business logic), open a span by hand with @opentelemetry/api:

import { trace, SpanStatusCode } from "@opentelemetry/api";
import { withRoute } from "@/lib/with-route";
 
const tracer = trace.getTracer("my-nextjs-app");
 
export const GET = withRoute("/api/orders/[id]", async (req, { params }) => {
  const { id } = await params;
 
  return tracer.startActiveSpan("fetch-order", async (span) => {
    try {
      span.setAttribute("order.id", id);
      const order = await db.orders.findById(id);
 
      if (!order) {
        span.setStatus({ code: SpanStatusCode.ERROR, message: "Order not found" });
        span.end();
        return Response.json({ error: "Not found" }, { status: 404 });
      }
 
      span.end();
      return Response.json(order);
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message });
      span.end();
      throw error;
    }
  });
});

Server Components

Wrap Server Component bodies in a span to see their render time in the trace:

import { trace } from "@opentelemetry/api";
 
const tracer = trace.getTracer("my-nextjs-app");
 
export default async function UsersPage() {
  return tracer.startActiveSpan("UsersPage.render", async (span) => {
    try {
      const users = await fetchUsers();
      span.setAttribute("user.count", users.length);
      return (
        <ul>
          {users.map((user) => (<li key={user.id}>{user.name}</li>))}
        </ul>
      );
    } finally {
      span.end();
    }
  });
}

Server Component spans appear as Spans in Traceway, nested under the parent HTTP request trace.

Nested spans

Spans created inside an active span are automatically linked as children:

async function processPayment(orderId: string) {
  return tracer.startActiveSpan("process-payment", async (span) => {
    try {
      await validateCard(orderId);
      await chargeAmount(orderId);
      span.setStatus({ code: SpanStatusCode.OK });
    } catch (error) {
      span.recordException(error as Error);
      span.setStatus({ code: SpanStatusCode.ERROR });
      throw error;
    } finally {
      span.end();
    }
  });
}

Recording an exception without re-throwing

withRoute already records errors that bubble up. If you catch an error and want to log it on the active span without throwing:

import { trace, SpanStatusCode } from "@opentelemetry/api";
 
export const POST = withRoute("/api/checkout", async () => {
  const span = trace.getActiveSpan();
  try {
    await processPayment();
  } catch (error) {
    if (span) {
      span.recordException(error as Error);
      span.setStatus({ code: SpanStatusCode.ERROR, message: (error as Error).message });
    }
    return Response.json({ error: "Payment failed" }, { status: 500 });
  }
  return Response.json({ status: "ok" });
});

Auto vs manual reference

OperationAuto-instrumented?Notes
HTTP requests (incoming)PartialwithRoute sets http.route + catches exceptions
Outgoing fetch() callsYesVia instrumentation-undici
Prisma queriesYesVia @prisma/instrumentation
Database queries (pg, mysql2, mongodb)YesCJS packages — patched by require-in-the-middle
Cache (redis, ioredis)YesCJS packages — patched automatically
SQLite (better-sqlite3)NoUse manual spans
Server Component renderingNoUse tracer.startActiveSpan()
Custom business logicNoUse tracer.startActiveSpan()

Environment variable alternative

Instead of hardcoding the endpoint and token, use standard OTel environment variables:

export OTEL_SERVICE_NAME="my-nextjs-app"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://your-traceway-instance.com/api/otel"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-project-token"

With these set, your instrumentation.ts can omit the exporter URLs and headers — the SDK picks them up automatically.

Test the backend integration

Add a test API route:

// app/api/test-error/route.ts
import { withRoute } from "@/lib/with-route";
 
export const GET = withRoute("/api/test-error", async () => {
  throw new Error("Test error from Next.js server");
});

Visit /api/test-error and check your Traceway dashboard — the error should appear under Issues with a full stack trace, and the endpoint should be grouped as GET /api/test-error.


Next Steps