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. The Delivr pixel therefore 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. It explains why re-injecting the script on every route change is the wrong approach. It also gives drop-in examples for the most common frameworks.


Quick Recipe

  1. Inject p.js once in your root layout or index.html.
  2. On every route change, call:
window.PixelSDK.setGlobalParams({
  path: window.location.pathname,
  // ...any other per-route global params
});
window.PixelSDK.trackPageView();

The initial page view fires automatically when the pixel loads. You only need the two calls above for subsequent in-app navigations.

Installing via Google Tag Manager? Add the pixel with the Delivr.ai Pixel tag template, then use the script-only helper below for route changes.

Installing via a tag manager and cannot 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 is tempting to just drop the <script> tag into every page component and let it re-run on each route. Do not. 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. Other tracked events repeat in the same way. You will see duplicated events in your dashboard, inflated pixel traffic, and gradual memory growth as listeners accumulate.

On a route change, re-injecting p.js creates a new closure that adds click, submit and scroll listeners while the old listeners stay attached, so each click sends N duplicate events. Using the SDK API instead, setGlobalParams plus trackPageView, sends a single page_view event and reuses the existing listeners.

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 data-global-params or data-config-id for anything you want set at app bootstrap.

If you cannot call trackPageView from your environment, add data-auto-spa="true" to the script tag. The SDK then wraps the history API itself and fires a route change on every SPA navigation, with no code from you. It is the intended path for script-only installs such as Google Tag Manager, WordPress and Webflow. Leave it off when you are calling trackPageView yourself, which is the rest of this guide.

<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. Use the runtime API

The SDK exposes these runtime methods. setConsent gates transmission only when the script tag carries data-consent-category. See the consent section of the Resolution as a Service guide.

MethodPurpose
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.
setConsent(granted)Updates a consent-managed pixel. true activates an SDK that is waiting. false stops event and identity-enrichment transmissions. Requires data-consent-category on the script tag.

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

For a consent-managed installation, your integration must do all of the following:

  • Read the CMP's initial decision.
  • Set it on window.__delivrPixelConsent before the async script loads.
  • Call setConsent whenever the CMP reports a change.

The SDK does not read a banner or GPC signal itself. A US implied-consent policy may start true without a click. Pass false when the visitor opts out or when your CMP maps a recognized GPC signal to denied marketing consent. Express-consent regions start false until grant. See Connect Your Consent Manager for the full pattern.


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 and do not want to touch your app's JavaScript, paste this second <script> tag. Put it immediately after the pixel tag. The tag listens for SPA route changes and fires page_view events for you. Examples of pasted tags are Google Tag Manager, WordPress header injection, Webflow custom code, a Shopify-less storefront, and plain HTML.

<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. Effectively all SPAs use it, for example React Router, Next.js, Vue Router, Angular and Svelte.

When to use this instead of the framework-specific examples below: if you cannot modify your app's code or do not want to modify it, 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). 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 behavior is usually what you want (things like environment, tenant_id, version stay stable across routes). But it means previous route-specific keys persist if you do not 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 /a

Fix: explicitly null out keys you want cleared, or always pass the full per-route object:

PixelSDK.setGlobalParams({ path: "/b", section: null });

Common Pitfalls

PitfallWhat happensFix
Re-injecting p.js on every route changeDuplicate events multiply with each navigationInject once. Use setGlobalParams + trackPageView
Mutating data-global-params on the existing script tag at runtimeNothing. The attribute is read once on load.Use setGlobalParams
Calling trackPageView before p.js has loadedwindow.PixelSDK is undefined, call is a no-opGuard with if (!window.PixelSDK) return; or queue the call
Firing trackPageView on the initial routeYou get a duplicate of the automatic initial page viewSkip the first router event (see isFirstRender pattern in examples)
Forgetting to clear per-route paramsStale fields leak into subsequent routesExplicitly set keys to null or pass a full object each time
Your router fights the email token removalAfter a visitor lands from an email link, the pixel removes dlvr_et from the URL with history.replaceState. A router that restores its own URL state can put it back or log a warningAdd data-strip-email-token="false" to the script tag. The token is still redacted from stored events. See Identify Email Recipients.

What If I Already Re-Injected in Production?

If you have been re-injecting the script on route changes, you have accumulated DOM listeners in any session that crossed multiple routes. Two things to do:

  1. Ship the single-injection pattern above. Old sessions will clear themselves when the user hard-refreshes or closes the tab.
  2. Do not remove old <script> tags as a fix. The listeners are attached to document and window, not to the script tag. Removing the tag therefore 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 is 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._initialized

Trigger 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


Did this page help you?