Frontend tracing for Next.js
Trace App Router navigations and error boundaries in the browser with @maple-dev/browser, and join the first page load to the Next.js server render in one OpenTelemetry trace.
This page sets up @maple-dev/browser in a Next.js App Router app: a span for every page load and navigation, named after the route template (navigate /projects/[id]), errors from error.tsx and global-error.tsx, and a first page load that joins the server render’s trace.
It needs Next.js 15.3 or later, for instrumentation-client.ts. The examples use a src/ directory and were tested with Next.js 16.4, @vercel/otel 2.3 and @maple-dev/browser 0.14.
Quick setup with a coding agent
Copy this prompt into Claude Code, Codex, Cursor or another agent that can run shell commands. It installs the maple-frontend-tracing skill, which contains every step on this page.
Set up Maple frontend tracing in this project.
Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-frontend-tracing -y`, then follow it. This app uses Next.js.
My Maple public ingest key is maple_pk_... and my organization is in the US region.
Install
npm install @maple-dev/browser @vercel/otelpnpm add @maple-dev/browser @vercel/otelbun add @maple-dev/browser @vercel/otel@vercel/otel traces the server, so the page load can join its trace. Add this to package.json and run npm install again:
{
"overrides": { "@opentelemetry/api": "^1.10.0" }
}
Without it, npm installs an @opentelemetry/api that is too old for the browser SDK, and the build fails with Export logs doesn't exist in target module.
Initialize the SDK
instrumentation-client.ts runs after the HTML loads and before React hydrates. Put it next to instrumentation.ts:
// src/instrumentation-client.ts
import { MapleBrowser } from "@maple-dev/browser"
MapleBrowser.init({
ingestKey: process.env.NEXT_PUBLIC_MAPLE_INGEST_KEY, // public key, maple_pk_...
serviceName: "acme-web",
environment: process.env.NODE_ENV,
tracing: {
propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//],
},
})
- Use the public
maple_pk_key from Settings → Ingestion. Next.js inlinesNEXT_PUBLIC_*variables at build time, so set it where you build. EU organizations addregion: "eu". init()tracesfetchandXMLHttpRequest, records uncaught errors, Web Vitals and session replay. The Browser SDK reference lists every option.- Same-origin requests (route handlers, Server Component payloads) always carry
traceparent.propagateTraceHeaderCorsUrlsadds APIs on other origins. Each must allowtraceparentinAccess-Control-Allow-Headers, or the browser blocks the request. List only your own APIs.
Trace navigations
Next.js reports when a client navigation starts (onRouterTransitionStart), but not when it ends or which route it matched. This file starts the span on that hook and ends it once the new route has rendered, with the route template rebuilt from useParams(). Copy it as is:
// src/maple-navigation.tsx
"use client"
import { MapleBrowser } from "@maple-dev/browser"
import { useParams, usePathname, useSelectedLayoutSegment } from "next/navigation"
import { useEffect } from "react"
// The path the navigation in flight is going to
let pending: string | undefined
export function startNavigation(path: string) {
pending = path
MapleBrowser.startNavigation(path)
}
// Next.js calls this when a client-side navigation starts
export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
const path = new URL(url, location.href).pathname
// Already on its way there, e.g. a double click
if (path === pending) return
// Only the query or hash changed. Back, forward and redirects have already updated location, so they pass.
if (navigationType !== "traverse" && pending === undefined && path === location.pathname) return
startNavigation(path)
}
// Ends the navigation once the new route has rendered
export function MapleNavigation() {
const pathname = usePathname()
const params = useParams()
const notFound = useSelectedLayoutSegment() === "/_not-found" // the root not-found.tsx
useEffect(() => {
// A redirect renders the old URL first: wait for the final one
if (pending !== location.pathname) return
pending = undefined
MapleBrowser.endNavigation(notFound ? "/_not-found" : routeTemplate(pathname, params))
}, [pathname, params, notFound])
return null
}
// `/projects/8f2a` → `/projects/[id]`. Next.js doesn't expose the matched route in the browser.
function routeTemplate(pathname: string, params: Record<string, string | string[] | undefined>): string {
const segments = pathname.split("/")
for (const [name, value] of Object.entries(params)) {
if (value === undefined) continue
const parts = Array.isArray(value) ? value : [value]
const at = segments.findLastIndex((_, i) => parts.every((part, j) => segments[i + j] === part))
if (at > 0) segments.splice(at, parts.length, Array.isArray(value) ? `[...${name}]` : `[${name}]`)
}
return segments.join("/")
}
Start the page load and export the hook from instrumentation-client.ts:
// src/instrumentation-client.ts
import { MapleBrowser } from "@maple-dev/browser"
import { startNavigation } from "./maple-navigation"
MapleBrowser.init({ /* as above */ })
// Next.js reports client-side navigations only, so the page load starts here
startNavigation(location.pathname)
export { onRouterTransitionStart } from "./maple-navigation"
Render MapleNavigation once in the root layout. It needs a <Suspense> boundary: with Cache Components on, reading the pathname outside one fails the render.
// src/app/layout.tsx
import { Suspense } from "react"
import { MapleNavigation } from "@/maple-navigation"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Suspense>
<MapleNavigation />
</Suspense>
</body>
</html>
)
}
Data loading
Nothing to add. init() traces every fetch and XMLHttpRequest in the browser, and @vercel/otel traces Server Components on the server.
- On a client navigation, Next.js fetches the route’s payload (
GET /projects/abc?_rsc=…). That request is its own trace, with theRSC GET /projects/[id]server span under it, not under thenavigatespan. Prefetches are separate traces too. - Optional: wrap related requests in
MapleBrowser.tracedto group them under one named span. Only requests started before the firstawaitinside it nest under it, so start them together:
const [project, members] = await MapleBrowser.traced("load project", () =>
Promise.all([fetch(`/api/projects/${id}`), fetch(`/api/projects/${id}/members`)]),
)
Report errors
Each error.tsx is a React error boundary, so the errors it catches never reach the SDK’s global handlers. Report them from an effect:
// src/app/error.tsx
"use client"
import { MapleBrowser } from "@maple-dev/browser"
import { useEffect } from "react"
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
useEffect(() => {
// A digest means a Server Component threw: Next.js already recorded it on its server span
if (!error.digest) MapleBrowser.captureException(error)
}, [error])
return (
<main>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</main>
)
}
Add the same effect to global-error.tsx, which replaces the whole document when the root layout throws.
- A Server Component error reaches the browser with a
digest. Next.js records the original exception on its server span (render route (app) /routeon a page load,RSC GET /routeon a client navigation), so the browser skips it. - Each error is recorded once.
captureExceptionskips an error object the SDK already recorded, such as a root layout error the global handler caught innext dev. - A client component that throws on a full page load fails twice: on the server render span, and in the browser. Those are two executions of the bug. After a client navigation, only the browser one happens.
global-error.tsxunmountsMapleNavigation, so the navigation that hit the error ends as interrupted.
Join the page load to the server render
The pageload span joins the server’s trace when the page carries a <meta name="traceparent"> tag. Next.js renders one with its experimental clientTraceMetadata option, from the trace that instrumentation.ts sets up:
// src/instrumentation.ts
import { OTLPHttpProtoTraceExporter, registerOTel } from "@vercel/otel"
export function register() {
registerOTel({
serviceName: "acme-next",
traceExporter: new OTLPHttpProtoTraceExporter({
url: "https://ingest.maple.dev/v1/traces", // EU: https://ingest.eu.maple.dev/v1/traces
headers: { authorization: `Bearer ${process.env.MAPLE_INGEST_KEY}` },
}),
instrumentationConfig: {
// Server-side fetch() only sends traceparent to these origins
fetch: { propagateContextUrls: [/^https:\/\/api\.acme\.com\//] },
},
})
}
// next.config.ts, in the config object
experimental: {
clientTraceMetadata: ["traceparent"],
},
- A page load of a dynamic or partially prerendered route is one trace:
GET /projects/[id]at the root, the render and itsfetchspans (with your API’s spans under them), andpageload /projects/[id]with itsdocumentFetchanddomProcessingchildren. - Static (prerendered) pages get no tag, so their
pageloadstarts its own trace.next devrenders every page on request, so there every page load joins. propagateContextUrlslists your APIs on other origins:@vercel/otelonly propagates to the deployment’s own URLs by default.
Verify
Run next build and next start, then check in Traces:
- Load a dynamic route: one trace with
GET /projects/[id]andpageload /projects/[id]under it. - Click a link:
navigate /projects/[id]in its own trace, never a span named after the concrete URL. - Open
/does-not-exist:pageload /_not-found. - Throw in a client component: one error on the Errors page.
In next dev, Strict Mode runs effects twice, so requests started in an effect are traced twice.
What the spans look like
- The first route is
pageload /projects/[id], every later onenavigate /projects/[id], with the concrete path inurl.path. Catch-all routes are named[...slug]. - Query and hash changes start no span. Back and forward are navigations.
- A navigation started before the previous one ended ends that one with
maple.navigation.interrupted. - The span ends when the new route commits. On a route with a
<Suspense>boundary orloading.tsx, that’s when the fallback shows, not when the streamed content arrives. redirect()during a client navigation gives an unnamednavigatespan for the old path, ended as interrupted, thennavigate /new-route. A redirect on a page load is an HTTP redirect, so there’s onepageloadfor the target.- A URL that matches no route is
/_not-found. A<Link>to one makes Next.js reload the page: thenavigatespan ends as interrupted, and the new document reportspageload /_not-found.notFound()in a matched route keeps that route’s template. - Templates are rebuilt from param values, so a static segment with the same value as a param, like
/users/settings/settingsfor/users/[name]/settings, can be named wrong. An optional catch-all[[...slug]]is named[...slug].
Next steps
- Frontend tracing overview: every framework guide.
- Browser SDK reference: sampling, consent, masking, logs and URL redaction.
- Errors and issues: how reported errors are grouped into issues.
- Instrument your application: backend guides, so browser traces continue into your services.