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.
Client-side navigation
Section titled “Client-side navigation”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"anddata-signal-delayelements are re-scanned after each navigation, so declarative events work on routed pages.
Loading the script
Section titled “Loading the script”Put the tag in index.html. It is outside React, which is where it belongs.
<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>Use next/script in the root layout so it loads once for the whole app.
import Script from 'next/script';
export default function RootLayout({ children }) { return ( <html lang="en"> <body> {children} <Script src="https://cdn.snipform.io/api/analytics/signals.js?site=YOUR_PROPERTY_KEY" strategy="afterInteractive" /> </body> </html> );}Pages Router: the same <Script> in pages/_app.tsx.
Vue: the tag in index.html, as for React.
Nuxt: register it once in nuxt.config.ts.
export default defineNuxtConfig({ app: { head: { script: [ { src: 'https://cdn.snipform.io/api/analytics/signals.js?site=YOUR_PROPERTY_KEY', defer: true }, ], }, },});Mark it is:inline so Astro leaves it alone.
<script is:inline src="https://cdn.snipform.io/api/analytics/signals.js?site=YOUR_PROPERTY_KEY" defer></script>With view transitions, Astro navigates through history.pushState, which the tracker already handles.
Calling signals() from components
Section titled “Calling signals() from components”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)); }, []); // ...}<script setup>const track = (name, value, meta) => window.signals?.(name, value, meta);</script>
<template> <button @click="track('plan_selected', plan)">Choose {{ plan }}</button></template>A type declaration keeps TypeScript quiet:
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 {};Talking to your own API
Section titled “Talking to your own API”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.
Server-side rendering
Section titled “Server-side rendering”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.
Development
Section titled “Development”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.