Skip to main content

Single Page Application (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:

FrameworkRouterAuto-Tracking
Reactreact-router v5 / v6Yes
Vuevue-router v3 / v4Yes
Angular@angular/routerYes
Next.jsApp Router / Pages RouterYes
Nuxt.jsnuxt/routerYes
Sveltesvelte-routing, SvelteKitYes
Remix@remix-run/reactYes
AstroView Transitions / client:loadYes
Solid@solidjs/routerYes

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

Installation

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

<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:

<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:

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:

<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.

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 — 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.
<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:

// 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

// 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.

// 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

// 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+)

// 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

// 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

// 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

// 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)

<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

<!-- 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

// 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

// 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

// 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

// 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.

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:

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 does not de-duplicate manual callssealmetrics() 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) 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:

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:

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.
  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);
}
});