Back

TanStack Router + Datadog RUM: How to?

January 04, 2026 ––– views
TanStack Router
Datadog

This has been the result of a couple of days engineering the Datadog RUM’s codebase and trying to figure out what the hell is going on. The reason why it’s so complex is that there’s some very light documentation, but it misses some key points, like why it’s important and such.

But let me give you the context first.

The problem with router integrations

I run TanStack Router in production within PayFit’s main app. But the Datadog RUM integration only has React Router as an official integration. And so I wanted to explore what exactly we gain from this integration. Even though I read through the documentation, it wasn’t super clear to me.

So I ended up doing some reverse engineering within the Datadog codebase (which is public, thankfully).

Why you should care about router integration

Here’s what I discovered the integration actually does:

Better route grouping

The integration allows Datadog to have a clear understanding of the route’s name instead of using the raw URL. Because if you have a route parameter that isn’t an ID but like a verb or a word, the logs won’t consider them as the same route. They’ll split them differently even though it’s the same route pattern.

From my experiments, Datadog will treat as an id if it looks like a mongo id, uuid, or a number. Otherwise, it’ll treat it as a different route. But if you have a parameter with, let’s say, the locale (so /$locale/users), /fr/users and /en/users would be considered as different routes, even if they are the same. With the router integration, it’ll be grouped together under /$locale/users.

Clearer parameter visibility

The second benefit is that the route in the logs would have the actual parameter name from your codebase instead of a question mark. So this gives you way better insight into what routes are actually being hit.

So, for example, given this router url /users/$id, the Datadog RUM integration would show the route as /users/$id instead of /users/?. Now, imagine you have multiple ids in the url, that would be much clearer!

That’s great. But now that we figured out why we should do it, let’s figure out how.

How the integration works

Like I mentioned, there was only React Router official integration without much insight. So I looked into the actual code, and there are actually two sides to this.

The RUM plugin

First, there’s a plugin for RUM. This API seems undocumented and experimental, but it basically allows you to hook into RUM integration to set configuration options. But I guess if you have control over your own configuration, you can easily set the options you need for this. The router integration only sets the trackViewsManually option to true when you enable the router integration.

Manual view tracking

The second part is actually tracking views manually. Because the main difference is that you opt out of automatically triggering views and you trigger views manually while giving them a name.

For React Router, they ended up wrapping createRoutes and createRouter and a bunch of APIs from React Router to do this. However, in TanStack Router, I don’t want to wrap everything in a wrapper call.

The TanStack Router approach

In TanStack Router, there’s an API that allows you to subscribe to router events. And we can subscribe to the onResolved event that’s triggered when the router changed something and finished resolving.

Most importantly, we have an option in this, a path change flag, that allows us to figure out if the URL changed or not. And this is very important because the startView API on Datadog should only be called when you change URLs. If the path doesn’t change, it should stay the same.

It wasn’t super clear to me why, but that’s the information from React Router. So I figured it was important.

The implementation

Disclaimer: this has been tested with TanStack Router v1 and Datadog RUM v6. Api may change in the future.

And now, to do this, it was pretty easy. I could subscribe with onResolved. Then, if the path changed, you can trigger the view. But you have to figure out what the name of the route is.

This was pretty straightforward because in TanStack Router, you have a list of matches in the route state. And the last match is the one that is the leafiest of it, and that contains the full path, the full path with parameter placeholders.

And so this is exactly what you need to send to Datadog.

Here’s the full snippet that you should be able to use:

instrumentTanStackRouter.ts
import type { RegisteredRouter } from '@tanstack/react-router'
import { datadogRum } from '@datadog/browser-rum'
export function instrumentTanStackRouter(router: RegisteredRouter): () => void {
// Subscribe to the 'onResolved' event which fires after navigation is complete
// and the route is ready to render. This is the best event for tracking views
// because it ensures all route data is loaded and resolved.
const unsubscribe = router.subscribe('onResolved', event => {
if (event.pathChanged) {
startTanStackRouterView({
router,
})
}
})
// Track the initial view
// TanStack Router initializes with state already populated
startTanStackRouterView({
router,
})
// Return cleanup function
return unsubscribe
}
function startTanStackRouterView({ router }: { router: RegisteredRouter }) {
const routeMatches = router.state.matches
if (routeMatches.length === 0) {
// In case of no match, ignore
return
}
const lastMatch = routeMatches[routeMatches.length - 1]
if (!lastMatch) {
// In case of no match, ignore
return
}
let viewPath = '/'
if (typeof lastMatch.fullPath === 'string') {
// TanStack Router provides `fullPath` on the last route match which contains
// the complete parameterized path with $param syntax already in place.
// This is exactly what we need for view names!
// Example: "/company/collaborators/$collaboratorId/contracts/$contractId/create"
viewPath = lastMatch.fullPath
}
// Start a view manually with the name of the route
datadogRum.startView({
name: viewPath,
})
}

And then, to use it, you need to call it in your app’s root component:

app.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { instrumentTanStackRouter } from './instrumentTanStackRouter'
import { datadogRum } from '@datadog/browser-rum';
datadogRum.init({
applicationId: '<APP_ID>',
clientToken: '<CLIENT_TOKEN>',
// This is the important part!
trackViewsManually: true
});
// Your existing TanStack Router setup
const router = createRouter({
routeTree,
})
// This is the important part!
instrumentTanStackRouter(router)
const rootElement = document.getElementById('app')!
const root = ReactDOM.createRoot(rootElement)
root.render(<RouterProvider router={router} />)

Going further with context

You can even go further by including more context to that. What I ended up doing was adding the full location, the resolved location, the parameters, the search, everything! All of which can be collected from the router state. So that when you view an event, you can have everything you need directly within the event’s context. You can do so with the setGlobalContextProperty api. Be careful though, as the setGlobalContext will override any existing global context properties, whereas the setGlobalContextProperty will only override the key you specify.

instrumentTanStackRouter.ts
function startTanStackRouterView({ router }: { router: RegisteredRouter }) {
// ... rest of the code is the same as before
const routerState = {
location: router.state.location,
resolvedLocation: router.state.resolvedLocation,
params: lastMatch.params ?? {},
leafRouteId: lastMatch.routeId as string,
matches: routeMatches.map(({ routeId }) => ({ routeId })),
}
datadogRum.setGlobalContextProperty('routerState', routerState)
}

With this, I have a pretty good setup that allows me to have the router integration working. I can get the full route name directly within the Datadog UI, every RUM event has the router state, and I can easily add more context to it.

Why subscription beats wrapping

Yeah, I do wish this was a formal API though. But in the meantime, this is better than nothing. And I believe it’s in a better state to subscribe rather than to wrap everything, because the router is such a key part of the app, that I want to make sure it’s as robust as possible.

Having a subscription also acts as a nice boundary between the router and the rest of the app, ensuring that the router doesn’t have any side effects, or at least, less!

It keeps your integration loose and maintainable. And honestly? That’s how integrations should work.

I hope this was useful to you! It was quite a journey to figure this out, but it’s been super useful at PayFit, having more than 200 routes in our main app!

Share this article on Twitter
Or on BlueSky