Skip to content

SPAs & Frameworks

The tracker is framework-agnostic: it is one script tag, it reads its config from its own src, and it handles client-side routing by itself. The only rule is that it must be loaded as its own <script> from the CDN, not imported into your bundle.

The script patches history.pushState and history.replaceState and listens to popstate and hashchange. Whenever location.href changes, the current page view is closed with a final ping (page_nav), and a new view opens for the new URL with the new title and referrer. Concurrent navigations collapse into one re-initialisation.

Nothing to call. Router-driven apps get one page view per route, exactly like a multi-page site.

Two details worth knowing:

  • A change of hash alone also counts as a navigation, because single-page routers still exist that route on the hash. If your app uses hashes for in-page anchors, each anchor click will open a new view.
  • data-signal-on="visible" and data-signal-delay elements are re-scanned after each navigation, so declarative events work on routed pages.

Put the tag in index.html. It is outside React, which is where it belongs.

index.html
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script src="https://cdn.snipform.io/api/analytics/signals.js?site=YOUR_PROPERTY_KEY" defer></script>
</body>

signals is a global on window, not an import. Guard for it, because the script loads asynchronously and may not have run when your component mounts, and await ready for anything that fires on mount.

const track = (name: string, value?: string | number, meta?: Record<string, unknown>) =>
window.signals?.(name, value, meta);
export function PricingCard({ plan }) {
return <button onClick={() => track('plan_selected', plan)}>Choose {plan}</button>;
}
export function Checkout({ sessionTotal }) {
useEffect(() => {
window.signals?.ready.then(() => window.signals('checkout_viewed', sessionTotal));
}, []);
// ...
}

A type declaration keeps TypeScript quiet:

signals.d.ts
interface SignalsApi {
(name: string, value?: string | number | Record<string, unknown> | null, meta?: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
readonly sessionId: string | null;
readonly ready: Promise<string>;
readonly optedOut: boolean;
revenue(value: number): Promise<{ success: boolean }>;
acquisition(params: { value?: number; currency?: string; tags?: string[] }): Promise<{ success: boolean }>;
identify(id: string | { external_id?: string; email?: string; traits?: Record<string, unknown> }, traits?: Record<string, unknown>): Promise<{ success: boolean; contact_id?: string | null }>;
optOut(): Promise<boolean>;
optIn(): Promise<boolean>;
attachToFetch(options?: { origins?: string[] }): void;
attachToForm(target: HTMLFormElement | string, fieldName?: string): HTMLFormElement | null;
bindTo(url: string, options?: Record<string, unknown>): void;
cookieTo(name?: string | Record<string, unknown>, options?: Record<string, unknown>): void;
readonly headerName: 'X-Snipform-Session-Id';
readonly formFieldName: 'snip_session_id';
}
declare global {
interface Window { signals?: SignalsApi; }
}
export {};

In an SPA the natural bridge to your backend is the header. One call after the script tag and every same-origin fetch or XHR carries the session id:

window.addEventListener('signals:ready', () => window.signals.attachToFetch());

Your API then forwards it to SnipForm when something worth recording happens. See Session Handoff.

The script runs in the browser only. It is safe to include in SSR output because it does nothing until it executes client-side, and window.signals is undefined during render, which the optional chaining above already handles.

The property’s registered base domain is enforced on load, so a page served from localhost is rejected for a production property. Add &debug=true to the script URL to see the rejection and every request in the console, and verify tracking on a staging host under the property’s domain (subdomains are accepted) or on a separate property registered for your staging domain.