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:
- Fire a pageview on initial load.
- Fire a pageview when your router calls
history.pushState(). - Fire a pageview when your router calls
history.replaceState(). - Fire a pageview when the user clicks Back / Forward (
popstate). - Skip duplicates: if the new URL is identical to the previous one, no event is sent.
- Update the
referrerof 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:
| Framework | Router | Auto-Tracking |
|---|---|---|
| React | react-router v5 / v6 | ✅ |
| Vue | vue-router v3 / v4 | ✅ |
| Angular | @angular/router | ✅ |
| Next.js | App Router / Pages Router | ✅ |
| Nuxt.js | nuxt/router | ✅ |
| Svelte | SvelteKit | ✅ |
| Remix | @remix-run/react | ✅ |
| Astro | View 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.
Recommended: set the group at script load
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
BrowserRouteroverHashRouter— 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:
- Script included twice. Search the rendered HTML for
t.sealmetrics.com/t.js— there should be exactly one match. - Manual
sealmetrics()call after auto-tracking. The tracker already fires onpushState/replaceState/popstate. Don't call it again in your router'safterEachunless you've confirmed auto-tracking isn't firing. - Per-route grouping pattern. See Option A above — calling
sealmetrics({ group: '...' })after an automatic pageview produces a second event. - Animations using
replaceState. Some libraries callhistory.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
- Confirm your router uses the History API (open DevTools → Console and run
history.pushState({}, '', '/test')— the tracker should fire a pageview). - If you use hash routing, add the hashchange listener.
- If your router bypasses
pushState, fire manually in theafterEachhook.
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);
}
});
Related Documentation
- SPA Support — framework-specific examples
- Conversions — full conversion property reference
- Microconversions
- Tracker Installation