Skip to main content

How to Track SPA Sites

SealMetrics tracks Single Page Applications (SPAs) automatically. The tracker hooks the History API, fires a pageview on initial load and on every route change, and de-duplicates same-URL events. No router-specific code is needed for any framework that uses pushState / replaceState.


Quick Start

Add the standard tracker script to your site (once, in the document <head>):

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

That's it. From this point on 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. Update the referrer of each SPA pageview to the previous in-app URL — so internal navigation flows are preserved in reports.

The tracker exposes itself as three global aliases — use whichever you prefer:

window.sealmetrics === window.sm === window._sm

Supported Frameworks

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

FrameworkRouterAuto-Tracking
Reactreact-router v5 / v6
Vuevue-router v3 / v4
Angular@angular/router
Next.jsApp Router / Pages Router
Nuxt.jsnuxt/router
SvelteSvelteKit
Remix@remix-run/react
AstroView Transitions / client:load
Solid@solidjs/router

Hash-based routers (/#/path) are not auto-detected — see Hash-Based Routing below.


Tracking Conversions in SPAs

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

React

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

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

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

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

Vue 3

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

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

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

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

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

Angular

// checkout-success.component.ts
import { Component, Input, OnInit } from '@angular/core';

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

@Component({
selector: 'app-checkout-success',
template: '<div>Thank you for your order!</div>',
})
export class CheckoutSuccessComponent implements OnInit {
@Input() order!: { id: string; total: number; currency: string };

ngOnInit() {
if (typeof window.sealmetrics === 'undefined') return;

window.sealmetrics.conv('purchase', this.order.total, {
order_id: this.order.id,
currency: this.order.currency,
});
}
}

Next.js (App Router)

// 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, {
order_id: order.id,
currency: order.currency,
});
});
}, [searchParams.order]);

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

Tracking Microconversions

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.

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',
});
}

Content Grouping

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

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

<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 — Suppress auto-tracking, fire manually

Best for apps where every route needs a specific group. Use a custom script tag without defer and short-circuit auto-tracking by deferring the script attribute. Then call sealmetrics({ group: '...' }) yourself after each navigation:

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

⚠️ Because the tracker auto-fires on pushState, you'll get two pageviews for the same URL: the automatic one (no group) and your manual one (with group). Until a disableAuto flag exists, prefer Option B for most cases.

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.


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:

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:

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

The tracker de-duplicates by URL, so this is safe to call even if the History API hook also fires — only one pageview will be sent per unique URL transition.


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:

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 (see the example in Tracking Conversions).


TypeScript Declarations

Drop this in types/sealmetrics.d.ts (or any .d.ts picked up by your tsconfig):

interface SealmetricsOptions {
group?: string;
}

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

declare global {
// eslint-disable-next-line no-var
var sealmetrics: SealmetricsFunction;
// eslint-disable-next-line no-var
var sm: SealmetricsFunction;
// eslint-disable-next-line no-var
var _sm: SealmetricsFunction;
}

export {};

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.


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. See Option A above — calling sealmetrics({ group: '...' }) after an automatic pageview produces a second event.
  4. Animations using replaceState. Some libraries call history.replaceState() for non-navigation purposes (e.g. saving scroll position into the URL hash). This will trigger a pageview if the full URL changes. Use a query-param-only update where possible, or strip the param before tracking.

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.
  3. If your router bypasses pushState, fire manually in the afterEach hook.

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:

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

Or retry once after a microtask:

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