Skip to content
Maple Docs
Open app

Frontend tracing for TanStack Router and TanStack Start

Trace TanStack Router navigations and loaders as OpenTelemetry traces linked to your backend, report route errors, and join TanStack Start's server render to the browser page load.

This page connects a TanStack Router app to Maple with @maple-dev/browser: a span per navigation named after the route’s full path, a span per loader with its fetch calls and your backend’s spans under it, and route errors reported once. With TanStack Start, the browser’s page load joins the server render’s trace.

Tested with @tanstack/react-router 1.170, @tanstack/react-start 1.169 and @maple-dev/browser 0.14.0.

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 of this guide.

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 TanStack Router (and TanStack Start, if it renders on the server).

My Maple public ingest key is maple_pk_... and my organization is in the US region.

Use your public key from Settings → Ingestion. Without one, the agent uses a placeholder you can replace later. EU organizations should say EU region.

Install and initialize

npm install @maple-dev/browser @opentelemetry/api
pnpm add @maple-dev/browser @opentelemetry/api
bun add @maple-dev/browser @opentelemetry/api
// src/maple.ts
import { MapleBrowser } from "@maple-dev/browser"

MapleBrowser.init({
	ingestKey: import.meta.env.VITE_MAPLE_INGEST_KEY, // public key, maple_pk_...
	serviceName: "acme-web",
	environment: import.meta.env.MODE,
})

Import it first in src/router.tsx, which both the Start and the Router scaffolds have. The same file reports errors that route error boundaries catch:

// src/router.tsx
import "./maple" // first: init() runs before the router exists (a no-op on the server)
import { MapleBrowser } from "@maple-dev/browser"
import { createRouter, ErrorComponent } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"

export function getRouter() {
	return createRouter({
		routeTree,
		// ...your existing options
		defaultErrorComponent: ErrorComponent, // defaultOnCatch only runs where a route has an error component
		// Errors the route error boundaries catch never reach window.onerror
		defaultOnCatch: (error) => MapleBrowser.captureException(error),
	})
}

For an EU organization, add region: "eu". Every other option is in the Browser SDK reference.

Connect browser traces to your API

Same-origin requests, including Start server functions, carry a traceparent header, so the backend’s span joins the browser’s trace. For an API on another origin, list it in propagateTraceHeaderCorsUrls and allow the header in its CORS preflight, or the browser blocks the request:

// src/maple.ts
MapleBrowser.init({
	// ...
	tracing: {
		propagateTraceHeaderCorsUrls: [/^https:\/\/api\.acme\.com\//],
	},
})
Access-Control-Allow-Headers: content-type, authorization, traceparent

List only your own APIs. The backend reads the header through its OpenTelemetry HTTP instrumentation (Instrument your application).

Trace navigations

instrumentTanStackRouter from @maple-dev/browser/react opens a span when the router starts a navigation and ends it when the route resolves.

TanStack Start: call it in an effect in the root route’s component (RootDocument in the scaffold), so it attaches after hydration. Attached in getRouter(), the page load never ends and the first click closes it as interrupted.

// src/routes/__root.tsx
import { instrumentTanStackRouter } from "@maple-dev/browser/react"
import { useRouter } from "@tanstack/react-router"
import { useEffect } from "react"

function RootDocument({ children }: { children: React.ReactNode }) {
	const router = useRouter()
	// One pageload/navigate span per route change; returns the unsubscribe
	useEffect(() => instrumentTanStackRouter(router), [router])
	// ...the rest of your root component
}

TanStack Router without Start: call it in src/main.tsx right after creating the router, before rendering:

const router = getRouter()
instrumentTanStackRouter(router) // before the first render, so the first route's loaders nest under the page load

What you get:

  • Spans are named after the leaf route’s fullPath: pageload /projects/$projectId, then navigate /projects/$projectId. The concrete path is in url.path.
  • Search-only changes (?tab=members) and hash links start no span.

Trace loaders

Wrap each loader, and any beforeLoad that does I/O, in MapleBrowser.traced, so it gets a span under the navigation with its requests under it. redirect() and notFound() are thrown, so mark them as not failures:

// src/traced-loader.ts
import { MapleBrowser } from "@maple-dev/browser"
import { isNotFound, isRedirect } from "@tanstack/react-router"

// redirect() and notFound() are thrown, but they aren't failures
export const tracedLoader = <T>(name: string, fn: () => Promise<T>) =>
	MapleBrowser.traced(name, fn, { isFailure: (error) => !isRedirect(error) && !isNotFound(error) })
// src/routes/projects.$projectId.tsx
export const Route = createFileRoute("/projects/$projectId")({
	loader: ({ params }) =>
		tracedLoader("loader /projects/$projectId", () =>
			Promise.all([fetchProject(params.projectId), fetchMembers(params.projectId)]),
		),
	component: Project,
})

The browser has no async context: only requests started before the first await in fn nest under the loader span. Start independent requests together with Promise.all; for a request that needs an earlier result, carry the context across the await:

import { context } from "@opentelemetry/api"

loader: ({ params }) =>
	tracedLoader("loader /seq/$id", async () => {
		const ctx = context.active() // before the first await
		const project = await fetchProject(params.id)
		const members = await context.with(ctx, () => fetchMembers(project.id))
		return { project, members }
	}),
  • Preloads (defaultPreload: "intent") run the loader before the click, so its span is a trace of its own and the navigate span has no loader span.
  • Server functions send their request after an internal await, so a call from a loader is its own trace (browser fetch span plus the server’s spans), not nested under the loader.
  • On the server, traced only runs fn. A Start page load’s loader time is in the server’s request span and its HTTP client spans.

Report route errors

A route’s own onCatch replaces defaultOnCatch; call MapleBrowser.captureException(error) there too. Errors thrown in event handlers are recorded by the SDK’s global handler as browser.uncaught_error.

Join the server render (TanStack Start)

The page load joins the server’s trace when the HTML response has a Server-Timing: traceparent;desc="…" entry. The server has to be instrumented with OpenTelemetry and write it.

  1. Start the OpenTelemetry Node SDK from the Node.js guide, preloaded with --import in front of your server. Its HTTP instrumentation opens a span per request, with the server-side loader fetches under it. In development:

    NODE_OPTIONS="--import ./tracing.mjs" vite dev

    Leave out the guide’s import-in-the-middle register() call under vite dev: it breaks loading the Vite config, and the HTTP and undici instrumentations don’t need it.

  2. Write the active trace into HTML responses from a custom server entry:

    // src/server.ts
    import { context, propagation } from "@opentelemetry/api"
    import handler, { createServerEntry } from "@tanstack/react-start/server-entry"
    
    export default createServerEntry({
    	async fetch(request) {
    		const response = await handler.fetch(request)
    		// Hand the server's trace to the browser's pageload span (HTML responses only)
    		if (response.headers.get("content-type")?.startsWith("text/html")) {
    			const carrier: { traceparent?: string } = {}
    			propagation.inject(context.active(), carrier)
    			if (carrier.traceparent) response.headers.append("server-timing", `traceparent;desc="${carrier.traceparent}"`)
    		}
    		return response
    	},
    })

The pageload span becomes a child of the server’s request span, in one trace with the server’s fetches and your API’s spans. Later navigations start their own traces. If a CDN caches your HTML, strip the header from cached responses, or every visitor’s page load joins the same trace.

Verify

In Traces, filtered to your serviceName:

  • A reload gives one pageload <fullPath> span; with Start, under the server’s request span.
  • A click gives one navigate <fullPath> span, with the loader … span, its fetch spans and your API’s spans under it.
  • A click before the previous navigation resolves ends that span unnamed (navigate) with maple.navigation.interrupted: true.
  • A redirect() during a client navigation does the same: an interrupted span with the original url.path, then one for the target route. On a first page load, Start’s server sends the redirect, and the page load is named after the target.
  • A loader that throws notFound() keeps its route’s name. A URL no route matches is named after the root route (navigate /); use url.path to find those.

Next steps