React Router and Remix integration

React Router (Remix) analytics, error tracking and uptime

BugOtter works with React Router v7 framework mode and Remix v2. One script tag in app/root.tsx gives you cookieless analytics and browser errors. @sentry/react-router, pointed at your BugOtter DSN, catches errors in loaders and actions. Uptime and a status page start when you add the URL.

  • 1 tagin app/root.tsx
  • v7 or Remix v2both work
  • 3 regionsuptime checks

What BugOtter gives a React Router app

  • Analytics with no banner

    One tag in the root Layout. Links, navigate() and <Form> are counted.
  • Loader and action errors

    @sentry/react-router with your BugOtter DSN. The script covers the browser.
  • Past ad blockers

    A resource route at /_bo/* forwards three paths from your own domain.
  1. Script in app/root.tsx

    Paste the tag; the CLI writes your keys. Details
    Terminalnpx bugotter init --token bo_…
  2. Proxy resource route

    Optional, needs a server. Details
  3. Server errors

    Init before the server starts. Details
    Terminal: npm install @sentry/react-router
  4. Upload source maps

    From build/client, then delete them. Details
  5. Uptime and deploys

    A health route and deploy markers. Details
    Last deploy step: npx bugotter deploy

Analytics: the script in your root Layout

  1. Add the script to app/root.tsx

    The Layout export renders the document for every route, including your error boundary. Put the tag in its <head>. It loads after the page, sets no cookies and needs no banner.

    app/root.tsx
    // app/root.tsx
    import { Links, Meta, Scripts, ScrollRestoration } from "react-router";
    
    export function Layout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="en">
          <head>
            <meta charSet="utf-8" />
            <meta name="viewport" content="width=device-width, initial-scale=1" />
            <Meta />
            <Links />
            <script defer src="https://bugotter.com/bo.js" data-app="YOUR_APP_ID" />
          </head>
          <body>
            {children}
            <ScrollRestoration />
            <Scripts />
          </body>
        </html>
      );
    }
  2. Navigation is counted on its own

    Links and navigate() change pages with history.pushState. The script follows that and the back button, so each new path is a page view, with time on page, scroll depth and Web Vitals for each. Keep signed-in pages out with data-exclude="/dashboard".

  3. Goals from components

    Call window.bo?.track() from an event handler. The ?. covers the moment before the deferred script has loaded. Forms, downloads and outbound links need no code.

    app/components/upgrade-button.tsx
    // app/components/upgrade-button.tsx
    export function UpgradeButton({ plan }: { plan: string }) {
      return (
        <button onClick={() => window.bo?.track("upgrade_click", { plan })}>
          Upgrade
        </button>
      );
    }

    Using TypeScript? Declare window.bo once, so track() type-checks:

    app/bo.d.ts
    declare global {
      interface Window {
        bo?: {
          track(name: string, props?: Record<string, string | number | boolean>): void;
          visitorId(): string | null;
        };
      }
    }
    export {};

npx bugotter init --token bo_… writes BUGOTTER_DSN, BUGOTTER_PROJECT and BUGOTTER_KEY to .env.local and prints the tag. It doesn't edit root.tsx, so paste the tag as above.

BugOtter visitors chart for one site: daily bars, the previous period as a dashed line, and markers for big payments and an error spike
  1. Payments on the chart. A green marker shows the day a big payment came in.
  2. Error spikes, too. A red marker lines up a bad day with the error behind it.
  3. Last period, dashed, so a drop or a launch spike is obvious at a glance.
  4. Bots kept out of every number, with the count one click away.
One app's visitors in the live demo, with payments, error spikes and the last period on the same chart.

Get past ad blockers with a resource route

A resource route on your own domain forwards three paths to BugOtter, and nothing else.

20–40%

of visitors are dropped by ad blockers for most analytics

Two small files bring them back.

First, declare the route in app/routes.ts, so the _bo path stays literal:

app/routes.ts
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("_bo/*", "bo-proxy.ts"), // outside routes/, so file-based routing ignores it
] satisfies RouteConfig;

Then the route itself: a loader and an action that forward to BugOtter.

app/bo-proxy.ts
// app/bo-proxy.ts — a resource route: a loader and an action, no component
import type { LoaderFunctionArgs } from "react-router";

const BO = "https://bugotter.com";
const KEY = process.env.BUGOTTER_PROXY_KEY;
const TARGET: Record<string, string> = {
  "bo.js": `${BO}/bo.js`,
  "api/collect": `${BO}/api/collect?via=${KEY}`,
  "api/errors": `${BO}/api/errors?via=${KEY}`,
};

async function forward({ request, params }: LoaderFunctionArgs) {
  const url = TARGET[params["*"] ?? ""];
  if (!url) return new Response("Not found", { status: 404 });
  const headers = new Headers();
  for (const h of ["content-type", "user-agent", "origin", "referer"]) {
    const v = request.headers.get(h);
    if (v) headers.set(h, v);
  }
  const res = await fetch(url, {
    method: request.method,
    headers,
    body: request.method === "POST" ? await request.text() : undefined,
  });
  return new Response(res.body, {
    status: res.status,
    headers: {
      "content-type": res.headers.get("content-type") ?? "text/plain",
      "cache-control": res.headers.get("cache-control") ?? "no-store",
    },
  });
}

export const loader = forward; // GET /_bo/bo.js
export const action = forward; // POST /_bo/api/collect and /_bo/api/errors

Set BUGOTTER_PROXY_KEY on the server, then change the tag to src="/_bo/bo.js". Using flatRoutes()? Spread it into the array and add the same route() line next to it.

Check it once: curl -sI https://yourapp.com/_bo/bo.js should answer 200. Your proxy key is on the app's Traffic tab in BugOtter. Keep the ?via= part: it proves visits came through your proxy, and it only lives in server config, never in the page.

Errors: @sentry/react-router with your BugOtter DSN

Browser errors already come from the script. For loaders, actions and server rendering, use Sentry's React Router SDK and point it at BugOtter. No Sentry account, wizard or Vite plugin needed.

  1. Install the SDK

    terminal
    npm install @sentry/react-router
  2. Init it before the server starts

    instrument.server.mjs
    // instrument.server.mjs (project root)
    import * as Sentry from "@sentry/react-router";
    
    Sentry.init({
      dsn: process.env.BUGOTTER_DSN,
      dataCollection: { userInfo: false, cookies: false, httpBodies: [], databaseQueryData: false },
    });

    Load it before anything else when the server starts. The same package.json also uploads source maps after the build:

    package.json
    // package.json
    "scripts": {
      "build": "react-router build && npx bugotter sourcemaps build/client --delete",
      "start": "NODE_OPTIONS='--import ./instrument.server.mjs' react-router-serve ./build/server/index.js"
    }

    The dataCollection line is for SDK v11; on v10 use sendDefaultPii: false. Leave out tracesSampleRate: BugOtter keeps errors, not traces.

  3. Report errors from loaders and actions

    app/entry.server.tsx
    // app/entry.server.tsx (run `npx react-router reveal` if you don't have one)
    import * as Sentry from "@sentry/react-router";
    // On Vercel or Netlify, where you can't set NODE_OPTIONS, import it here instead:
    // import "../instrument.server.mjs";
    
    export const handleError = Sentry.createSentryHandleError({ logErrors: false });

    Skip entry.client.tsx changes: the script covers the browser, and the Sentry browser SDK would report the same errors twice.

Kept

  • Exceptions and captureMessage events, grouped into issues
  • Release, environment, the request path and runtime
  • Navigation and failed-request breadcrumbs
  • Your user id, hashed (never stored raw)

Accepted, then dropped

  • Performance traces and spans
  • Session replays and profiles
  • Sessions, logs and metrics
  • Console breadcrumbs and click text
A BugOtter error page: a ChunkLoadError on /pricing with a plain summary, Fix with agent, events and users, 14-day bars and the source-mapped line
  1. A plain summary written from the evidence: page, browser, release and visits.
  2. Fix with agent hands the whole issue to Claude Code, Cursor or Codex.
  3. Where your code broke, mapped back to your file and line with your source maps.
One error in the live demo: a plain summary, who it hit, and the line in your code.

Source maps from build/client

In vite.config.ts, set build: { sourcemap: "hidden" }. Vite writes the maps with no link to them in your scripts.

  • Upload: the build script above uploads every map in build/client (bundles load from /assets/…).
  • Private: --delete removes them before deploy, so your source stays private.
  • Keys and release: the CLI reads BUGOTTER_PROJECT and BUGOTTER_KEY from the build environment and tags the upload with the commit.

Details in the source maps guide.

Uptime, a status page and deploys

3 regions

check your app from the moment you add the URL

Add a resource route that touches your database and check it too, so a broken connection shows up as an outage:

app/routes/api.health.ts
// add route("api/health", "routes/api.health.ts") to app/routes.ts
import { db } from "~/db.server";

export async function loader() {
  await db.execute("select 1");
  return Response.json({ ok: true });
}

In BugOtter, expect the JSON value ok to be true.

  • Deploys on Vercel: automatic once connected.
  • Deploys on Netlify: add your app's deploy webhook as a Deploy succeeded notification.
  • Deploys on Fly, Railway or your own server: run npx bugotter deploy as the last step.
  • Status page: at bugotter.com/status/your-app, with a README badge. Pro can use your own domain.
BugOtter uptime for one app: 30 days of daily bars with two amber days, a response time chart, and the latest check from US East, Europe and US West
  1. Every day at a glance. Amber means a short outage that day.
  2. Response time, median and p95, and the slowest region.
  3. Three regions check your URL. An outage only counts when two agree.
Thirty days of uptime in the live demo, with response times and the latest check from each region.

Or ask your coding agent

Connect BugOtter to Claude Code, Cursor or Codex once over MCP. It signs in through your browser, read-only unless you allow changes. Then paste one line.

Claude Code, once
claude mcp add --transport http --scope user bugotter https://bugotter.com/api/mcp

Then ask, in plain words:

  • Add BugOtter to this React Router app: the script in app/root.tsx, @sentry/react-router on the server with my BugOtter DSN, and source map upload from build/client.

  • Add the BugOtter ad-blocker proxy as a resource route at /_bo/* in app/routes.ts and switch the script to /_bo/bo.js.

  • Using BugOtter, find the loader errors that started after our last deploy and fix the top one.

Cursor, VS Code, Codex and the rest have their own one-step setup at Docs: your agent (MCP). Every setup screen in BugOtter also has a Copy prompt for your agent button with your keys filled in.

Questions

Is this for React Router v7 or Remix v2?

Both. React Router v7 in framework mode is what Remix v2 became, and the script tag goes in app/root.tsx in either. For server errors, use @sentry/react-router on React Router v7 and @sentry/remix on Remix v2. Remix v2 has no routes.ts, so name the proxy file app/routes/[_bo].$.ts instead: the brackets keep the underscore part of the URL.

Does BugOtter count client-side navigation?

Yes. React Router changes pages with history.pushState, which the script listens for, along with the back button. Each new path is one page view. navigate(to, { replace: true }) replaces the entry instead, so it isn't counted as a new page view. A change of search params alone isn't a new page view either.

Are <Form> submissions counted?

Yes. React Router's <Form> renders a real form and fires a real submit event, which the script counts as Form: <name>. It never reads what people type. Give a form a clear name with data-bo-name="signup", or skip one with data-bo-skip.

Why not app/routes/_bo.$.ts?

In file-based route names, a leading underscore makes a pathless layout, so _bo wouldn't be part of the URL. Declaring the route in routes.ts keeps the path literal, and keeping the file outside app/routes stops flatRoutes() from picking it up as a second route.

Does the proxy work in SPA mode?

No. With ssr: false there's no server to run a resource route. Put the three paths in your host's rewrites instead: Netlify, Vercel or a Cloudflare Pages Function.

Where is the status page?

At bugotter.com/status/your-app once you turn it on, with uptime and incidents only. Pro can serve it from your own domain.

Sources

Checked Nov 4, 2026 against React Router's docs on routes.ts and file route conventions, and Sentry's React Router manual setup. BugOtter isn't affiliated with Sentry, Shopify or the React Router team.

Watch every app you ship, from one calm list.

14 days of everything, no card needed. Plans from $10 a month, billed yearly.