Error Tracking for TanStack Start and TanStack Router Apps
EngineeringJuly 26, 202612 min read

Error Tracking for TanStack Start and TanStack Router Apps

TanStack Start's beforeLoad and loader errors vanish into the void unless you catch them yourself. Here's the setup for route-loader failures plus pageviews in one script.

Three hours into debugging a production issue last week, I finally found it. A beforeLoad function was throwing when the auth token expired mid-session. The page showed a blank white screen. No console errors. Network tab showed successful responses. React Query was caching stale data. The router just... stopped.

I'd wired up Sentry the normal way — global error boundary, window.onerror, the works. None of it fired. TanStack Start caught the error internally, rendered nothing, and moved on with its life. My error dashboard showed zero issues while users filed support tickets.

So I spent the next two days building the instrumentation I'm about to show you. It catches beforeLoad failures, loader errors, route-level exceptions, and the silent crashes that slip through generic error tracking. You'll get errors and pageviews in JustAnalytics without bolting Sentry, GA4, and LogRocket together. One script, under 5KB. (I'm probably over-engineering this, but after that three-hour debugging session? Worth it.)

What you'll have at the end

A TanStack Start application with:

  • Server-side error tracking that catches loader and beforeLoad failures during SSR
  • Client-side tracking for navigation errors and hydration failures
  • Route-aware pageviews with timing data
  • Source maps so your stack traces aren't gibberish
  • One script. Not three tools stapled together.

Prerequisites

  • TanStack Start 1.x (tested on 1.34+) with TanStack Router 1.x
  • Node.js 20+ for local development
  • A JustAnalytics account — free tier covers 100K events/month
  • You've used TanStack Router's file-based routing before (or at least read the docs)

Different framework? We've covered SvelteKit's handleError hooks, Next.js 15 App Router, and Django middleware setups separately. Same ideas, different plumbing.

Step 1: Create the error tracking utility

Start with a shared utility that both server and client can use. This keeps your tracking logic in one place.

// app/lib/analytics.ts
const SITE_ID = import.meta.env.VITE_JUSTANALYTICS_SITE_ID;
const API_KEY = import.meta.env.JUSTANALYTICS_API_KEY; // Server-only

interface ErrorPayload {
  type: 'loader' | 'beforeLoad' | 'component' | 'unknown';
  routeId: string;
  path: string;
  errorMessage: string;
  errorName: string;
  stackTrace?: string;
  phase?: 'ssr' | 'client' | 'navigation';
  [key: string]: unknown;
}

export async function trackError(payload: ErrorPayload): Promise<void> {
  if (!SITE_ID) return;

  const body = {
    site_id: SITE_ID,
    event: 'exception',
    properties: {
      ...payload,
      timestamp: new Date().toISOString(),
      stack_trace: payload.stackTrace?.slice(0, 5000), // Truncate massive stacks
    },
  };

  // Server-side: use fetch with API key
  if (typeof window === 'undefined' && API_KEY) {
    try {
      await fetch('https://api.justanalytics.app/v1/events', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify(body),
      });
    } catch {
      // Swallow — analytics shouldn't break your app
    }
    return;
  }

  // Client-side: use sendBeacon for reliability
  if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
    const blob = new Blob([JSON.stringify(body)], { type: 'application/json' });
    navigator.sendBeacon('https://api.justanalytics.app/v1/events/beacon', blob);
  }
}

export function trackPageview(path: string, routeId: string): void {
  if (typeof window === 'undefined' || !SITE_ID) return;

  const payload = {
    site_id: SITE_ID,
    event: 'pageview',
    properties: {
      path,
      route_id: routeId,
      referrer: document.referrer || undefined,
      title: document.title,
      screen_width: window.innerWidth,
    },
  };

  const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
  navigator.sendBeacon?.('https://api.justanalytics.app/v1/events/beacon', blob);
}

The ?.slice(0, 5000) on stack traces isn't paranoia. I once had a recursive loader blow up and generate a 60KB stack trace. That exceeded the API payload limit and my event quota exploded. The overage email was not fun to explain. My fault entirely — I should've caught that in testing.

Step 2: Wrap your loaders

Here's where TanStack Start differs from everything else. Loaders and beforeLoad functions don't throw to window.onerror. They throw inside the router, which catches them and renders an error boundary. If you don't instrument the loader itself, you'll never know it failed.

Create a wrapper function:

// app/lib/loaderWrapper.ts
import { trackError } from './analytics';

type LoaderFn<T> = (ctx: { params: Record<string, string>; context: unknown }) => Promise<T>;

export function withErrorTracking<T>(
  routeId: string,
  loaderType: 'loader' | 'beforeLoad',
  fn: LoaderFn<T>
): LoaderFn<T> {
  return async (ctx) => {
    try {
      return await fn(ctx);
    } catch (error) {
      const isServer = typeof window === 'undefined';

      await trackError({
        type: loaderType,
        routeId,
        path: ctx.params['*'] || routeId, // Catch-all or route ID
        errorMessage: error instanceof Error ? error.message : 'Unknown error',
        errorName: error instanceof Error ? error.name : 'UnknownError',
        stackTrace: error instanceof Error ? error.stack : undefined,
        phase: isServer ? 'ssr' : 'navigation',
        params: JSON.stringify(ctx.params),
      });

      throw error; // Re-throw so the router handles it normally
    }
  };
}

Now use it in your route files:

// app/routes/products/$productId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { withErrorTracking } from '../../lib/loaderWrapper';

export const Route = createFileRoute('/products/$productId')({
  beforeLoad: withErrorTracking('products/$productId', 'beforeLoad', async ({ params, context }) => {
    // Auth check, feature flags, whatever
    const session = await context.auth.getSession();
    if (!session) {
      throw new Error('Unauthorized');
    }
  }),

  loader: withErrorTracking('products/$productId', 'loader', async ({ params }) => {
    const res = await fetch(`/api/products/${params.productId}`);
    if (!res.ok) {
      throw new Error(`Product fetch failed: ${res.status}`);
    }
    return res.json();
  }),

  component: ProductPage,
});

The wrapper catches the error, tracks it, then re-throws so your error boundary still renders. You're not swallowing anything — just observing.

I've seen teams catch-and-return-null instead. Don't do this. Your users will see broken pages and you won't know why. At least with re-throwing, the error boundary gives them something. (Is this the "right" pattern? Probably not. But it works, and I'm done being clever.)

Step 3: Add a global error boundary with tracking

TanStack Router lets you define error boundaries at the route level or globally. The global one catches everything the route-level ones miss.

// app/routes/__root.tsx
import { createRootRoute, Outlet, useRouterState } from '@tanstack/react-router';
import { trackError } from '../lib/analytics';
import { useEffect } from 'react';

function RootErrorBoundary({ error }: { error: Error }) {
  const routerState = useRouterState();

  useEffect(() => {
    trackError({
      type: 'component',
      routeId: routerState.location.pathname,
      path: routerState.location.pathname,
      errorMessage: error.message,
      errorName: error.name,
      stackTrace: error.stack,
      phase: 'client',
    });
  }, [error, routerState.location.pathname]);

  return (
    <div style={{ padding: '2rem' }}>
      <h1>Something went wrong</h1>
      <p>We've been notified and are looking into it.</p>
      <button onClick={() => window.location.reload()}>Reload page</button>
    </div>
  );
}

export const Route = createRootRoute({
  component: () => <Outlet />,
  errorComponent: RootErrorBoundary,
});

This catches rendering errors — a component throws during render, React catches it, your boundary fires. It doesn't catch loader errors (those happen before render) or beforeLoad errors (those prevent navigation entirely). That's why you need the wrapper in Step 2. Three layers of error handling. Annoying? Yes. Necessary? Also yes.

Step 4: Track pageviews on navigation

Add a component that fires pageviews when the route changes:

// app/components/Analytics.tsx
import { useRouterState } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
import { trackPageview } from '../lib/analytics';

export function Analytics() {
  const routerState = useRouterState();
  const lastPath = useRef('');

  useEffect(() => {
    const currentPath = routerState.location.pathname;

    // Don't double-track on initial load or when path hasn't changed
    if (currentPath === lastPath.current) return;
    lastPath.current = currentPath;

    // Find the deepest matched route for better tracking granularity
    const matchedRoutes = routerState.matches;
    const routeId = matchedRoutes[matchedRoutes.length - 1]?.routeId || 'unknown';

    trackPageview(currentPath, routeId);
  }, [routerState.location.pathname, routerState.matches]);

  return null;
}

Mount it in your root layout:

// app/routes/__root.tsx (updated)
import { Analytics } from '../components/Analytics';

export const Route = createRootRoute({
  component: () => (
    <>
      <Analytics />
      <Outlet />
    </>
  ),
  errorComponent: RootErrorBoundary,
});

The lastPath ref prevents double-firing on initial load. Without it, you'd track twice — once when the component mounts, once when the reactive effect runs. I spent an embarrassing amount of time wondering why my bounce rate looked so good before realizing every visit counted as two pageviews. Rookie mistake.

Step 5: Handle SSR errors in your server entry

TanStack Start uses Vinxi under the hood. Server-side errors during SSR need their own handling:

// app/ssr.tsx (or wherever your SSR entry lives)
import { getRouterManifest } from '@tanstack/start/router-manifest';
import { createStartHandler, defaultStreamHandler } from '@tanstack/start/server';
import { createRouter } from './router';
import { trackError } from './lib/analytics';

export default createStartHandler({
  createRouter,
  getRouterManifest,
})(async (ctx) => {
  try {
    return await defaultStreamHandler(ctx);
  } catch (error) {
    // Track SSR-level failures that happen outside loaders
    await trackError({
      type: 'unknown',
      routeId: ctx.request.url,
      path: new URL(ctx.request.url).pathname,
      errorMessage: error instanceof Error ? error.message : 'SSR failure',
      errorName: error instanceof Error ? error.name : 'UnknownError',
      stackTrace: error instanceof Error ? error.stack : undefined,
      phase: 'ssr',
    });

    throw error;
  }
});

This catches errors that happen during the rendering phase of SSR — after loaders complete but before the HTML is sent. Different failure mode, same tracking destination.

Common errors and how to fix them

Look, I've hit all of these. Probably more than once.

"Cannot use import.meta.env on the server." You're mixing server-only and client-only environment variables. Use import.meta.env.VITE_* for anything the client needs. For server-only secrets like API keys, use process.env in server files. TanStack Start (via Vinxi) handles the separation, but you have to respect the boundaries.

Errors fire in dev but not in production. Your environment variables aren't deployed. Check that VITE_JUSTANALYTICS_SITE_ID is set in your production environment. Vercel, Netlify, and Cloudflare all have different patterns here. VeloCalls teams sometimes use environment-specific configs to track staging versus production separately — same error, different priorities.

Pageviews track but errors don't. Your API key is probably missing or invalid. Server-side tracking (loaders during SSR) requires the API key. Client-side tracking uses the beacon endpoint which only needs the site ID. Check both are set correctly.

beforeLoad errors cause infinite redirect loops. If your beforeLoad throws and your error boundary redirects (say, to /login), and /login also has a failing beforeLoad, you'll loop. Track these with a redirect counter in the error properties — if you see the same user hitting the same error 50 times in a minute, something's looping.

Stack traces are useless (minified). You need source maps. Add a build step that uploads them:

# In your CI/CD pipeline
pnpm build && \
curl -X POST https://api.justanalytics.app/v1/sourcemaps \
  -H "Authorization: Bearer $JUSTANALYTICS_API_KEY" \
  -F "release=$(git rev-parse HEAD)" \
  -F "sourcemaps=@.output/public/_build"

Tag your errors with the same release ID so the stack traces resolve correctly.

What this doesn't cover

I'm not going to pretend this is complete. It's not.

Third-party scripts that throw — ad SDKs, chat widgets, whatever — won't route through your error boundaries. You'd need a global window.onerror handler. Honestly, I ignore those unless they break my app. If your ad network's JavaScript crashes, that's their problem. For teams running performance marketing, pairing this with ClickzProtect helps identify when bad ad traffic correlates with error spikes.

Errors in Web Workers or Service Workers. Those have their own error events. If you're doing heavy Worker-based processing, you'll need separate instrumentation. JustBrowser teams sometimes run separate analytics profiles for Worker-heavy apps to track those surfaces.

Memory leaks and performance degradation. This is error tracking, not APM. JustAnalytics does both — the Pro plan ($49/month, or $39 billed annually) includes distributed tracing and P99 latency — but that's a different setup entirely. Check the observability consolidation guide if you're paying for Datadog or New Relic alongside this. Spoiler: you probably shouldn't be.

Next steps

You've got TanStack Start error tracking plus pageviews in one integration. Better than flying blind. Not perfect — but better.

From here:

  • Add custom event tracking for form submissions and conversions
  • Set up alerting so you know when error rates spike — the Pro plan includes Slack and PagerDuty integrations
  • Wire in session replay to see what users did before errors hit
  • Review your observability costs if you're running multiple tools

If you're building multi-tenant SaaS, DevOS pairs well for keeping development environments isolated. And for teams doing outbound email campaigns where bounce tracking matters, JustEmails shares similar instrumentation patterns.

Frequently Asked Questions

Why don't regular JavaScript error trackers catch TanStack Start loader errors?

TanStack Start loaders run on the server during SSR and inside the router context during client navigation. Generic browser SDKs only hook window.onerror and unhandled promise rejections — they miss server-side loader failures entirely. Client-side, the router catches loader errors internally and routes them through its own error boundary system. You need router-aware instrumentation that wraps loader functions and hooks into the router's error callbacks. It's more work than it should be. But that's frameworks for you.

How do I track beforeLoad errors separately from loader errors?

beforeLoad runs before loaders and handles authentication, redirects, and route guards. Wrap your beforeLoad functions with the same error tracking wrapper used for loaders, but tag them with a type property like 'beforeLoad' versus 'loader'. This lets you filter in your dashboard — auth failures (beforeLoad) have different root causes than data fetch failures (loader) and need different alerting thresholds.

Does this work with TanStack Start's streaming SSR?

Yes. Streaming SSR sends the shell immediately and streams loader results as they resolve. If a loader fails mid-stream, the error still routes through your wrapped loader function. The difference is timing — with streaming, users see partial content before the error renders. Track the stream_phase property (shell vs streamed) to understand when errors hit relative to the user's experience.

Can I use this alongside TanStack Query for data fetching?

Yes. TanStack Query handles its own error states through useQuery's error property and onError callbacks. For query errors, use Query's built-in handling and track via the onError option. For route-level loader errors (the ones that block navigation), use the wrapper pattern in this guide. Both can send to the same JustAnalytics endpoint — use the event name to distinguish them.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts