---
title: "SPA Support"
description: "How the Sealmetrics tracker auto-detects SPA navigation via the History API in React, Vue, Angular, Next.js and more, plus manual first-pageview control."
canonical_url: "https://docs.sealmetrics.com/implementation/tracker/spa-support"
lang: "en"
date_generated: "2026-08-12T08:27:36.924Z"
source_hash: "fad334a8ca7cc7dd0eeaf8befe0fd8527d05e46956878330971330b61aec28d1"
content_type: "implementation"
owner: "engineering"
llm_priority: "critical"
source_file: "implementation/tracker/spa-support.mdx"
publisher: "Sealmetrics"
---

# SPA Support

Canonical page: https://docs.sealmetrics.com/implementation/tracker/spa-support

The tracker automatically detects navigation in Single Page Applications. No configuration required, and no router-specific code for any framework built on the History API.

## How It Works

Once the script is on the page, the tracker will:

1. Fire a pageview on initial load.
2. Fire a pageview when your router calls `history.pushState()`.
3. Fire a pageview when your router calls `history.replaceState()`.
4. Fire a pageview when the user clicks **Back** / **Forward** (`popstate`).
5. Skip duplicates: if the new URL is identical to the previous one, no event is sent.
6. Set the `referrer` of each SPA pageview to the previous in-app URL — so internal navigation flows are preserved in reports.

The canonical global is **`window.sealmetrics`** — always use it in your integrations. (`window.sm` and `window._sm` exist as best-effort aliases, but they are only claimed when free — e.g. during a v1→v2 migration `window.sm` is the *old* tracker — so don't rely on them.)

## Supported Frameworks

Any router built on the History API is supported out of the box:

| Framework | Router | Auto-Tracking |
|-----------|--------|:-------------:|
| React | react-router v5 / v6 | Yes |
| Vue | vue-router v3 / v4 | Yes |
| Angular | @angular/router | Yes |
| Next.js | App Router / Pages Router | Yes |
| Nuxt.js | nuxt/router | Yes |
| Svelte | svelte-routing, SvelteKit | Yes |
| Remix | @remix-run/react | Yes |
| Astro | View Transitions / client:load | Yes |
| Solid | @solidjs/router | Yes |

Hash-based routers (`/#/path`) are **not** auto-detected — see [Hash-Based Routing](#hash-based-routing) below.

## Installation

Same as regular installation — one script tag, once, in the document `<head>`:

```html
<script src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID" defer></script>
```

## Manual Initial Pageview (`?auto=0`)

If you need to control the first pageview yourself (for example, to set a content group once your app has hydrated), load the tracker with `?auto=0`:

```html
<script src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID&auto=0" defer></script>
```

This suppresses **only** the automatic initial pageview. The SPA navigation listeners (`pushState` / `replaceState` / `popstate`) stay active, so subsequent route changes are still tracked automatically. Fire the first pageview yourself when ready:

```javascript
sealmetrics({ group: 'home' });
```

## Manual SPA Pageviews (`?spa=0`)

If you also want to fire the **route-change** pageviews yourself (for example, to attach a different content group per route), add `?spa=0`:

```html
<script src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID&auto=0&spa=0" defer></script>
```

With `spa=0` the History API listeners still run — they keep the tracker's URL/referrer state up to date so your manual pageviews carry the correct referrer chain — but they **no longer fire automatic pageviews**. You call `sealmetrics()` on every route change yourself.

Use `?auto=0&spa=0` together for fully manual control (e.g. the canonical GTM pattern, or per-route content grouping). ⚠️ Never fire manual route-change pageviews *without* `spa=0`: the automatic hook would fire too and every navigation would be counted twice.

## Content Grouping

Content groups let you bucket pageviews together (e.g. `blog`, `product`, `checkout`) and view aggregated reports per group.

### Recommended: set the group at script load

This is the cleanest pattern. The group is sticky for **every** automatic pageview, including all SPA navigations:

```html
<script
  src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID&group=storefront"
  defer
></script>
```

### Per-route grouping (advanced)

The tracker has **no API to update the group of an already-fired pageview**. If you need a different group per route, you have two options:

#### Option A — Full manual mode (`?auto=0&spa=0`), fire pageviews yourself

Best for apps where every route needs a specific group. Both flags are required:

- `auto=0` suppresses the **initial** auto-pageview on load.
- `spa=0` suppresses the **automatic pageview on SPA route changes**.

```html
<script src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID&auto=0&spa=0" defer></script>
```

With both flags set, no pageview fires unless you fire it — so you pass the group on every route, with no risk of double counting:

```jsx
// after router resolves a route
useEffect(() => {
  if (typeof sealmetrics === 'undefined') return;
  sealmetrics({ group: routeGroupFor(pathname) }); // pathname → 'blog' | 'product' | ...
}, [pathname]);
```

⚠️ Don't use `?auto=0` alone for this pattern: without `spa=0`, route changes via `pushState`/`replaceState`/`popstate` still fire an automatic (group-less) pageview, and your manual call would make it **two pageviews** for the same URL.

#### Option B — Use a single global group per site

Set the group once at script load (the recommended pattern above) and rely on URL paths in reports to slice further. This is what most SPAs should do.

## React Example

### With react-router

```jsx
// App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/products" element={<Products />} />
        <Route path="/products/:id" element={<ProductDetail />} />
        <Route path="/checkout" element={<Checkout />} />
      </Routes>
    </BrowserRouter>
  );
}
```

No additional setup needed. Navigation between routes is tracked automatically.

### Tracking Conversions

The pageview is automatic; you only need to fire the conversion itself on the success page or step. Always guard with `typeof sealmetrics !== 'undefined'` — the script is `defer`-ed and may not be ready on the very first paint of an SSR app.

```jsx
// CheckoutSuccess.jsx
import { useEffect } from 'react';

export default function CheckoutSuccess({ order }) {
  useEffect(() => {
    if (typeof sealmetrics === 'undefined') return;

    sealmetrics.conv('purchase', order.total, {
      currency: order.currency,
      payment_method: order.paymentMethod,
    });
  }, [order]);

  return <div>Thank you for your order!</div>;
}
```

### With Content Grouping

```jsx
// Requires ?auto=0&spa=0 on the script tag — see Option A above.

function ProductPage({ product }) {
  useEffect(() => {
    if (typeof sealmetrics === 'undefined') return;
    sealmetrics({ group: 'product' });
  }, [product.id]);

  return <div>{product.name}</div>;
}
```

## Next.js Example

### App Router (Next.js 13+)

```tsx
// app/layout.tsx
import Script from 'next/script';

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <Script
          src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID"
          strategy="afterInteractive"
        />
      </head>
      <body>{children}</body>
    </html>
  );
}
```

### Pages Router

```tsx
// pages/_app.tsx
import Script from 'next/script';

export default function MyApp({ Component, pageProps }) {
  return (
    <>
      <Script
        src="https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID"
        strategy="afterInteractive"
      />
      <Component {...pageProps} />
    </>
  );
}
```

### Conversion on a Success Page

```tsx
// app/checkout/success/page.tsx
'use client';

import { useEffect } from 'react';

export default function SuccessPage({ searchParams }: { searchParams: { order: string } }) {
  useEffect(() => {
    if (typeof window === 'undefined' || typeof sealmetrics === 'undefined') return;

    fetch(`/api/orders/${searchParams.order}`)
      .then((r) => r.json())
      .then((order) => {
        sealmetrics.conv('purchase', order.total, {
          currency: order.currency,
        });
      });
  }, [searchParams.order]);

  return <div>Thanks!</div>;
}
```

## Vue Example

### With vue-router

```javascript
// main.js
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
    { path: '/products', component: Products },
    { path: '/products/:id', component: ProductDetail }
  ]
});

createApp(App).use(router).mount('#app');
```

### Tracking a Conversion (Vue 3)

```vue
<script setup>
import { onMounted } from 'vue';

const props = defineProps(['order']);

onMounted(() => {
  if (typeof sealmetrics === 'undefined') return;

  sealmetrics.conv('purchase', props.order.total, {
    currency: props.order.currency,
  });
});
</script>

<template>
  <div>Thank you for your order!</div>
</template>
```

### Tracking Events in Components

```vue
<!-- ProductDetail.vue -->
<template>
  <div>
    <h1>{{ product.name }}</h1>
    <button @click="addToCart">Add to Cart</button>
  </div>
</template>

<script>
export default {
  methods: {
    addToCart() {
      if (typeof sealmetrics !== 'undefined') {
        sealmetrics.micro('add_to_cart', {
          product_id: this.product.id,
          product_name: this.product.name
        });
      }
      // ... add to cart logic
    }
  }
};
</script>
```

## Angular Example

### Module Setup

```typescript
// app.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'products', component: ProductsComponent },
  { path: 'products/:id', component: ProductDetailComponent }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}
```

### Tracking Service

```typescript
// analytics.service.ts
import { Injectable } from '@angular/core';

declare global {
  interface Window {
    sealmetrics: any;
  }
}

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  trackConversion(type: string, amount: number, properties?: object) {
    if (typeof window.sealmetrics !== 'undefined') {
      window.sealmetrics.conv(type, amount, properties);
    }
  }

  trackMicro(type: string, properties?: object) {
    if (typeof window.sealmetrics !== 'undefined') {
      window.sealmetrics.micro(type, properties);
    }
  }
}
```

## Nuxt.js Example

### Nuxt 3

```typescript
// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          src: 'https://t.sealmetrics.com/t.js?id=YOUR_ACCOUNT_ID',
          defer: true
        }
      ]
    }
  }
});
```

### Composable for Tracking

```typescript
// composables/useAnalytics.ts
export function useAnalytics() {
  const trackConversion = (type: string, amount: number, props?: object) => {
    if (process.client && typeof sealmetrics !== 'undefined') {
      sealmetrics.conv(type, amount, props);
    }
  };

  const trackMicro = (type: string, props?: object) => {
    if (process.client && typeof sealmetrics !== 'undefined') {
      sealmetrics.micro(type, props);
    }
  };

  return { trackConversion, trackMicro };
}
```

## Microconversions in SPAs

Use `micro()` for funnel steps that are **not** the final goal (add-to-cart, signup-started, video-played, etc.). All values in the properties map are sent as strings.

```javascript
function handleAddToCart(product) {
  // ... your cart logic

  if (typeof sealmetrics === 'undefined') return;

  sealmetrics.micro('add_to_cart', {
    product_id: product.id,
    product_name: product.name,
    price: String(product.price),
    currency: 'EUR',
  });
}
```

## Hash-Based Routing

Hash routing (`/#/path`) uses the `hashchange` event instead of the History API, so Sealmetrics does **not** track it automatically.

Add this listener once at app startup:

```javascript
window.addEventListener('hashchange', function () {
  if (typeof sealmetrics !== 'undefined') {
    sealmetrics();
  }
});
```

> **Migrating?** Hash routing is deprecated in most modern routers. If you're on react-router v6, prefer `BrowserRouter` over `HashRouter` — automatic tracking will then work without the listener.

## Frameworks With Custom Navigation

A few routers (or custom navigation code) bypass `pushState` / `replaceState` and update the URL via other mechanisms. In that case, call `sealmetrics()` manually inside your router's `afterEach` hook:

```javascript
// Vue Router
router.afterEach(() => {
  if (typeof sealmetrics !== 'undefined') sealmetrics();
});
```

⚠️ **The tracker does not de-duplicate manual calls** — `sealmetrics()` always sends a pageview when invoked. (The URL-change check only gates the *automatic* History API hook.) Only add this listener if your router genuinely bypasses `pushState`/`replaceState` — verify in the Network tab first. If both the automatic hook and your manual call fire, every navigation is counted **twice**. If you want deterministic control, load the tracker with `?spa=0` (see [Option A](#option-a--full-manual-mode-auto0spa0-fire-pageviews-yourself)) and fire all SPA pageviews manually.

## Server-Side Rendering (SSR)

The tracker only runs in the browser. In SSR frameworks (Next.js, Nuxt, SvelteKit, Remix), always check the environment **and** the global before calling:

```javascript
if (typeof window !== 'undefined' && typeof sealmetrics !== 'undefined') {
  sealmetrics.conv('purchase', 99.99, { currency: 'EUR' });
}
```

For Next.js App Router, wrap the call in `useEffect` inside a `'use client'` component.

## Iframes & Embedded Contexts

If the tracker script is loaded **inside an iframe** (for example, a Shopify Web Pixel sandbox), auto-pageview tracking and SPA listeners are intentionally **disabled** — only `conv()` and `micro()` remain exposed. This prevents duplicate pageviews when the same site loads Sealmetrics on both the parent page and an embedded pixel.

You don't need to do anything special: the tracker detects iframes via `window.self !== window.top`.

## TypeScript Declarations

Drop this in `types/sealmetrics.d.ts` (or any `.d.ts` picked up by your `tsconfig`) for better IDE support:

```typescript
interface SealmetricsOptions {
  group?: string;
}

interface SealmetricsFunction {
  (options?: SealmetricsOptions): void;
  conv(type: string, amount?: number, properties?: Record<string, string>): void;
  micro(type: string, properties?: Record<string, string>): void;
  readonly sessionId: string;
  readonly accountId: string;
  readonly tz: string;
  readonly autoMode: string;
}

declare global {
  const sealmetrics: SealmetricsFunction;
  const sm: SealmetricsFunction;
  const _sm: SealmetricsFunction;
}

export {};
```

## Troubleshooting

### Duplicate Pageviews

Most common causes, in order:

1. **Script included twice.** Search the rendered HTML for `t.sealmetrics.com/t.js` — there should be exactly one match.
2. **Manual `sealmetrics()` call after auto-tracking.** The tracker already fires on `pushState`/`replaceState`/`popstate`. Don't call it again in your router's `afterEach` unless you've confirmed auto-tracking isn't firing.
3. **Per-route grouping pattern.** Calling `sealmetrics({ group: '...' })` after an automatic pageview produces a second event. Use `?auto=0&spa=0` together — `?auto=0` alone only suppresses the *initial* pageview, not SPA route-change pageviews.
4. **Animations using `replaceState`.** Some libraries call `history.replaceState()` for non-navigation purposes (e.g. saving scroll position into the URL). This triggers a pageview if the full URL changes. Use a query-param-only update where possible, or strip the param before tracking.
5. **Another analytics wrapper re-triggering events.**

### Missing Pageviews on Route Change

1. Confirm your router uses the History API (open DevTools → Console and run `history.pushState({}, '', '/test')` — the tracker should fire a pageview).
2. If you use hash routing, add the [hashchange listener](#hash-based-routing).
3. If your router bypasses `pushState`, fire manually in the [`afterEach` hook](#frameworks-with-custom-navigation).

### Pageview Fires but `referrer` Is Wrong

For SPA navigations, Sealmetrics sets `referrer` to the **previous in-app URL** (not `document.referrer`). This is intentional — it lets you build true in-app flow reports. The original external referrer is only used on the very first pageview of the session.

### The `sealmetrics` Global Is Undefined

The script is loaded with `defer`, so it executes after the DOM is parsed but before `DOMContentLoaded`. If your app fires a conversion synchronously during initial render, the global may not exist yet. Always guard:

```javascript
if (typeof sealmetrics === 'undefined') return;
```

Or retry once after a microtask:

```javascript
queueMicrotask(() => {
  if (typeof sealmetrics !== 'undefined') {
    sealmetrics.conv('signup', 0);
  }
});
```

## Related Documentation

- [Conversions](/implementation/tracker/conversions) — full conversion property reference
- [Microconversions](/implementation/tracker/microconversions)
- [Tracker Installation](/implementation/tracker/installation)
- [How to Track React](/implementation/tracking-methods/how-to-track-react)
