All articles

Observability

Grafana Faro setup for BrowserRouter, createBrowserRouter, and no router

How to set up Grafana Faro route tracking for all three cases — legacy BrowserRouter with FaroRoutes, data router createBrowserRouter with withFaroRouterInstrumentation, and apps with no router at all.

JUL 19, 2026 3 min readBy Aryan Ranjan
AR / Observability

Sentry is a great error tracker. But when you need Web Vitals, session replays, route transition timing, and fetch performance all in one place — Grafana Faro is worth the switch.

Route tracking is where most Faro setups break, because the correct API depends entirely on which router you use. This guide covers all three cases with real code:

  1. Data router (createBrowserRouter) — createReactRouterV6DataOptions + withFaroRouterInstrumentation
  2. Legacy browser router (BrowserRouter) — createReactRouterV6Options + <FaroRoutes>
  3. No router (vanilla JS, Astro, form apps) — @grafana/faro-web-sdk only

If you're on BrowserRouter and following the official data-router docs, that mismatch is why your route events never show up. Jump to Case 2.

What Faro gives you that Sentry doesn't

SignalSentryFaro
JS errors
Web Vitals (LCP, CLS, INP)partial
Fetch / XHR timing
Route change tracking
Session context
User session replayseparate product

They can coexist. Different signals, different dashboards. You don't have to rip Sentry out.

Install

# React app
pnpm add @grafana/faro-react

# Vanilla JS / no React
pnpm add @grafana/faro-web-sdk

The singleton module

Always create one centralised init file. Never call initializeFaro in multiple places without a guard — the SDK will warn and produce duplicate telemetry.

// src/faro.ts
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-react';
import type { Faro } from '@grafana/faro-react';

let instance: Faro | undefined;

export function initialiseFaro(): void {
  const url = process.env.FARO_COLLECTOR_URL;

  // disable in local dev and if URL is missing
  if (!url || instance) return;

  instance = initializeFaro({
    url,
    app: {
      name: 'my-app',
      version: '1.0.0',
      environment: process.env.NODE_ENV,
    },
    instrumentations: [
      ...getWebInstrumentations(), // errors, console, Web Vitals, fetch
    ],
  });
}

export const getFaro = () => instance;

Call it once, before React mounts:

// entry.tsx
initialiseFaro();
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);

Order matters. Faro before React ensures startup errors are captured.


Grafana Faro router setup: the part the docs skip

Route tracking is where most migrations break. There are three cases.


Case 1 — Grafana Faro with createBrowserRouter (data router)

This is the modern React Router v6 setup. Faro has first-class support.

import {
  createReactRouterV6DataOptions,
  ReactIntegration,
} from '@grafana/faro-react';
import { matchRoutes } from 'react-router-dom';
import { withFaroRouterInstrumentation } from '@grafana/faro-react';

// in initializeFaro instrumentations:
new ReactIntegration({
  router: createReactRouterV6DataOptions({ matchRoutes }),
})

// wrap the router:
const router = withFaroRouterInstrumentation(
  createBrowserRouter(routes)
);

withFaroRouterInstrumentation subscribes to router.subscribe() — an internal API only available on data routers. This is why it won't work on anything else.


Case 2 — Grafana Faro with BrowserRouter (legacy browser router)

Many apps — especially older ones or micro-frontends — use BrowserRouter. The data router APIs don't exist here.

createReactRouterV6DataOptions will silently fail or throw. Don't use it.

Use createReactRouterV6Options instead, and swap <Routes> for <FaroRoutes> in JSX:

import {
  createReactRouterV6Options,
  ReactIntegration,
  FaroRoutes,
} from '@grafana/faro-react';
import {
  createRoutesFromChildren,
  matchRoutes,
  Routes,
  useLocation,
  useNavigationType,
} from 'react-router-dom';

// in initializeFaro instrumentations:
new ReactIntegration({
  router: createReactRouterV6Options({
    createRoutesFromChildren,
    matchRoutes,
    Routes,
    useLocation,
    useNavigationType,
  }),
})
// in your app JSX — replace <Routes> with <FaroRoutes>
<BrowserRouter>
  <FaroRoutes>{routeList}</FaroRoutes>
</BrowserRouter>

FaroRoutes is a thin wrapper — it renders your actual <Routes> internally and fires EVENT_ROUTE_CHANGE on every PUSH or POP navigation. Behaviour is identical to <Routes> from the user's perspective.

All five dependencies are required:

DependencyPurpose
createRoutesFromChildrenconverts JSX <Route> tree → route objects
matchRoutesresolves current URL to a route pattern
Routesrendered internally by FaroRoutes
useLocationdetects URL changes
useNavigationTypefilters REPLACE navigations (redirects) to avoid noise

Case 3 — Grafana Faro with no router (vanilla JS, Astro, form apps)

No React Router at all. No route tracking is needed — and that's fine.

Use @grafana/faro-web-sdk directly. Skip ReactIntegration entirely.

import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';

initializeFaro({
  url: COLLECTOR_URL,
  app: { name: 'my-vanilla-app', version: '1.0.0', environment: 'staging' },
  instrumentations: [...getWebInstrumentations()],
});

getWebInstrumentations() still gives you JS errors, console logs, Web Vitals, and fetch tracking — everything except route transitions, which don't exist in this context.

One Astro-specific gotcha: process.env doesn't exist in browser bundles. Use import.meta.env instead.

// Astro / Vite
const url = import.meta.env.PUBLIC_FARO_COLLECTOR_URL;

// Rsbuild / Webpack
const url = process.env.PUBLIC_FARO_COLLECTOR_URL;

Environment strategy

Faro can generate significant ingest volume. Don't run it everywhere.

const isEnabled =
  process.env.NODE_ENV !== 'development' &&
  !!process.env.FARO_COLLECTOR_URL;

if (!isEnabled || instance) return;

Keep FARO_COLLECTOR_URL empty in local dev env files. Faro skips init silently when the URL is missing — no errors thrown.


Verifying it works

Open DevTools → Network → filter by collect. You should see periodic POST requests.

Click one. The payload should contain:

{
  "meta": {
    "app": { "name": "my-app", "version": "1.0.0", "environment": "staging" }
  },
  "events": [...],
  "measurements": [...],
  "logs": [...]
}

Check meta.app.name. If you see a different app's name — another Faro instance initialised first (common in micro-frontend setups where the host shell runs its own Faro). Your singleton guard is working correctly; the host just got there first.


Custom telemetry

Once initialised, the API is straightforward:

import { getFaro } from './faro';

// log
getFaro()?.api.pushLog(['form submitted'], { level: 'info' });

// error
getFaro()?.api.pushError(new Error('payment failed'));

// product event
getFaro()?.api.pushEvent('batch_enrolled', { batchId: '123' });

// user context
getFaro()?.api.setUser({ id: 'user_456' });

Use getFaro()? — the optional chain handles the case where Faro was disabled (local dev or missing URL).


Summary

The migration is straightforward once you know which router API to use.

  • Data router (createBrowserRouter) → createReactRouterV6DataOptions + withFaroRouterInstrumentation
  • Legacy browser router (BrowserRouter) → createReactRouterV6Options + <FaroRoutes>
  • No router (vanilla JS, Astro) → @grafana/faro-web-sdk + getWebInstrumentations() only

Start with a singleton, disable locally, verify with the network tab. The rest is just wiring.