SPA Pixel Integration
Summary: Single-page apps (React, Next.js, Vue, Angular, Svelte) only load scripts once on the initial document load. Route changes happen in JavaScript without a full page reload, so the Delivr pixel needs to be told when a new page view happens and when per-route parameters change. This guide shows the supported pattern for installing the pixel in an SPA, why re-injecting the script on every route change is the wrong approach, and drop-in examples for the most common frameworks.
Quick Recipe
- Inject
p.jsonce in your root layout orindex.html. - On every route change, call:
window.PixelSDK.setGlobalParams({
path: window.location.pathname,
// ...any other per-route global params
});
window.PixelSDK.trackPageView();That's it. The initial page view fires automatically when the pixel loads; you only need the two calls above for subsequent in-app navigations.
Installing via a tag manager and can't modify app code? Skip to Script-only installation for a copy-paste snippet that handles SPA route changes without touching your framework.
Why You Should Not Re-Inject the Script on Route Changes
It's tempting to just drop the <script> tag into every page component and let it re-run on each route. Don't. Every execution of p.js:
- Attaches a fresh set of global DOM event listeners (click, submit, scroll, copy, idle timers, video events).
- Does not remove listeners from previous executions.
- Does not dedupe against a prior load.
The practical effect: after N script injections in a single session, every user click fires N click events to the Delivr API, every form submit fires N submit events, and so on. You'll see duplicated events in your dashboard, inflated pixel traffic, and gradual memory growth as listeners accumulate.
flowchart LR
A[Route change] --> B{Re-inject p.js?}
B -->|Yes| C[New closure]
C --> D[Adds click/submit/scroll listeners]
D --> E[Old listeners still attached]
E --> F[N duplicate events per click]
B -->|No, use SDK API| G[setGlobalParams + trackPageView]
G --> H[Single page_view event<br/>existing listeners reused]
style F fill:#ef4444,color:#fff
style H fill:#22c55e,color:#fff
The Supported Pattern
1. Inject the pixel once
Place the script tag in the single entry point that loads on every route. In practice this is your root layout, index.html, app.html, or equivalent. Use your data-global-params or data-pixel-config for anything you want set at app bootstrap.
<script
id="delivr-pixel"
src="https://cdn.delivr.ai/pixels/YOUR_PIXEL_ID/p.js"
data-global-params='{"environment":"production"}'
async
></script>The initial page view fires automatically when p.js finishes loading.
2. Signal route changes with the runtime API
Two methods on window.PixelSDK are all you need:
| Method | Purpose |
|---|---|
setGlobalParams(params) | Merges the given object into the pixel's globalParams. Every subsequent event (page views, clicks, form submits) includes these as static_params. |
trackPageView() | Sends a page_view event using the current window.location.href, document.title, and document.referrer. |
On each route change, call them in order: update params first, then fire the page view.
function onRouteChange() {
if (!window.PixelSDK) return; // script still loading
window.PixelSDK.setGlobalParams({
path: window.location.pathname,
// add any per-route fields your integration uses
});
window.PixelSDK.trackPageView();
}Framework Examples
Next.js App Router (13+)
// app/layout.tsx
import Script from "next/script";
import { PixelRouteListener } from "./pixel-route-listener";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Script
id="delivr-pixel"
src="https://cdn.delivr.ai/pixels/YOUR_PIXEL_ID/p.js"
strategy="afterInteractive"
/>
<PixelRouteListener />
{children}
</body>
</html>
);
}// app/pixel-route-listener.tsx
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useRef } from "react";
export function PixelRouteListener() {
const pathname = usePathname();
const searchParams = useSearchParams();
const isFirstRender = useRef(true);
useEffect(() => {
// Skip the first render; the initial page view fires automatically
// when p.js loads via the Script tag above.
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const sdk = (window as any).PixelSDK;
if (!sdk) return;
sdk.setGlobalParams({ path: pathname });
sdk.trackPageView();
}, [pathname, searchParams]);
return null;
}Next.js Pages Router
// pages/_app.tsx
import { useEffect } from "react";
import Script from "next/script";
import { useRouter } from "next/router";
export default function App({ Component, pageProps }) {
const router = useRouter();
useEffect(() => {
const handle = (url: string) => {
const sdk = (window as any).PixelSDK;
if (!sdk) return;
sdk.setGlobalParams({ path: url.split("?")[0] });
sdk.trackPageView();
};
router.events.on("routeChangeComplete", handle);
return () => router.events.off("routeChangeComplete", handle);
}, [router.events]);
return (
<>
<Script
id="delivr-pixel"
src="https://cdn.delivr.ai/pixels/YOUR_PIXEL_ID/p.js"
strategy="afterInteractive"
/>
<Component {...pageProps} />
</>
);
}React Router (v6+)
Put the script in index.html, then use a listener component inside your router:
// src/PixelRouteListener.tsx
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
export function PixelRouteListener() {
const location = useLocation();
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const sdk = (window as any).PixelSDK;
if (!sdk) return;
sdk.setGlobalParams({ path: location.pathname });
sdk.trackPageView();
}, [location.pathname, location.search]);
return null;
}// src/App.tsx
<BrowserRouter>
<PixelRouteListener />
<Routes>{/* ... */}</Routes>
</BrowserRouter>Vue Router (Vue 3)
// main.ts
import { createApp } from "vue";
import router from "./router";
import App from "./App.vue";
router.afterEach((to) => {
const sdk = (window as any).PixelSDK;
if (!sdk) return;
// Skip the very first navigation; p.js fires page_view automatically on load.
if ((window as any).__delivrFirstNavHandled !== true) {
(window as any).__delivrFirstNavHandled = true;
return;
}
sdk.setGlobalParams({ path: to.path });
sdk.trackPageView();
});
createApp(App).use(router).mount("#app");Script-only installation (no framework code)
If you install the pixel by pasting tags (Google Tag Manager, WordPress header injection, Webflow custom code, a Shopify-less storefront, plain HTML) and don't want to touch your app's JavaScript, paste this second <script> tag immediately after the pixel tag. It listens for SPA route changes and fires page_view events for you.
<script id="delivr-pixel"
src="https://cdn.delivr.ai/pixels/YOUR_PIXEL_ID/p.js"
data-global-params='{"environment":"production"}'
async></script>
<!-- Delivr SPA helper: fires page_view on in-app route changes -->
<script>
(function () {
function ping() {
if (!window.PixelSDK) return;
window.PixelSDK.setGlobalParams({ path: location.pathname });
window.PixelSDK.trackPageView();
}
var p = history.pushState;
var r = history.replaceState;
history.pushState = function () { p.apply(this, arguments); ping(); };
history.replaceState = function () { r.apply(this, arguments); ping(); };
window.addEventListener('popstate', ping);
})();
</script>No build step, no React, no router integration. Works with any SPA that uses the standard history API (which is effectively all of them: React Router, Next.js, Vue Router, Angular, Svelte, etc.).
When to use this instead of the framework-specific examples below: if you can't or don't want to modify your app's code, and you just need page views to fire on route changes. The framework-specific patterns give you tighter control (for example, firing only after data has loaded, or reading route params the framework already parsed), but this snippet covers the 90% case.
Plain history API (inside your app code)
If you already have JavaScript running in your app and would rather wrap history.pushState once from there instead of pasting a second <script> tag:
(function () {
function notifyDelivr() {
var sdk = window.PixelSDK;
if (!sdk) return;
sdk.setGlobalParams({ path: window.location.pathname });
sdk.trackPageView();
}
var _push = history.pushState;
var _replace = history.replaceState;
history.pushState = function () { _push.apply(this, arguments); notifyDelivr(); };
history.replaceState = function () { _replace.apply(this, arguments); notifyDelivr(); };
window.addEventListener("popstate", notifyDelivr);
})();Updating Per-Route Parameters
setGlobalParams merges into the existing globalParams; it does not replace them. That's usually what you want (things like environment, tenant_id, version stay stable across routes). But it means previous route-specific keys persist if you don't overwrite them.
// Route /a sets
PixelSDK.setGlobalParams({ path: "/a", section: "pricing" });
// Route /b later sets
PixelSDK.setGlobalParams({ path: "/b" });
// Result: { path: "/b", section: "pricing" }
// section leaked from /aFix: explicitly null out keys you want cleared, or always pass the full per-route object:
PixelSDK.setGlobalParams({ path: "/b", section: null });Common Pitfalls
| Pitfall | What happens | Fix |
|---|---|---|
Re-injecting p.js on every route change | Duplicate events multiply with each navigation | Inject once; use setGlobalParams + trackPageView |
Mutating data-global-params on the existing script tag at runtime | Nothing. The attribute is read once on load. | Use setGlobalParams |
Calling trackPageView before p.js has loaded | window.PixelSDK is undefined, call is a no-op | Guard with if (!window.PixelSDK) return; or queue the call |
Firing trackPageView on the initial route | You get a duplicate of the automatic initial page view | Skip the first router event (see isFirstRender pattern in examples) |
| Forgetting to clear per-route params | Stale fields leak into subsequent routes | Explicitly set keys to null or pass a full object each time |
What If I Already Re-Injected in Production?
If you've been re-injecting the script on route changes, you have accumulated DOM listeners in any session that crossed multiple routes. Two things to do:
- Ship the single-injection pattern above. Old sessions will clear themselves when the user hard-refreshes or closes the tab.
- Don't try to remove old
<script>tags as a fix. The listeners are attached todocumentandwindow, not to the script tag, so removing the tag does nothing to the listeners. A full page reload is the only way to clear them.
If you see a sudden drop in event volume after switching to the single-injection pattern, that's expected: you were previously sending duplicate events.
Checking Your Integration
Open your browser devtools on a page where the pixel is installed:
// Should be defined
window.PixelSDK
// Should reflect the script's data-global-params plus anything
// you've passed via setGlobalParams
window.PixelSDK.globalParams
// Should be true after the initial load
window.PixelSDK._initializedTrigger a route change in your app, then inspect the Network tab for a POST to /send-event with event_type: "page_view" and the new URL in event_data.url. If you see two requests for the same click or page view, the script is being loaded more than once; check for a second <script> tag or a re-injection in your routing code.
See Also
- Event Types Reference: what each event captures
- Pixel Platform Compatibility: which hosting platforms support the pixel
- How Identity Resolution Works: the pixel-to-person pipeline
Updated about 9 hours ago