Skip to content
Twilight React Playground
ثيم رائدaren

Track page views

Advanced8 min

Send the first page view on mount and every later one from the route::changed event the engine fires after navigation.

Goal: send a page view to your analytics tool for every page the shopper sees: the first one, and every later one, even though the storefront changes pages without reloading.

Mechanism: after the first page, the store is a single-page app: clicking a link swaps the page in the browser instead of loading a new document, so analytics scripts that count page loads miss those views. The engine fills the gap: after every in-app navigation it fires the Salla SDK event route::changed with the new page's data (slug, id, title, url…). The first page is different: the server rendered it, and no event fires for it, so your tracker sends that one itself when it mounts.

1. Try it

Runs in the browser…

Or watch it from the browser console:

Browser console
// Paste in the browser console on any page of this playground, then click around the store.
Salla.event.on('route::changed', (page) => console.log('route::changed', page.slug, page));

2. Write the tracker

app/components/PageViewTracker.tsx
import { useEffect } from 'react';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { getSallaSDK, type RouteChangedEvent } from '@salla.sa/twilight-theme-engine/utils';

type PageData = Partial<RouteChangedEvent>;

/** Sends one page view. Replace the body with your analytics tool's call. */
function sendPageView(page: PageData) {
  const tagManager = window as Window & { dataLayer?: unknown[] };
  tagManager.dataLayer = tagManager.dataLayer || [];
  tagManager.dataLayer.push({
    event: 'page_view',
    page_type: page.slug,
    page_title: page.title,
    page_id: page.id,
  });
}

// Module scope, not state: React runs effects twice in development (Strict Mode),
// and the first view must still be sent once.
let firstViewSent = false;

/** Mount once, on every page (for example in the body:end hook slot). Renders nothing. */
export function PageViewTracker() {
  useEffect(() => {
    // The page the server rendered fires no route::changed: send it from here.
    if (!firstViewSent) {
      firstViewSent = true;
      const page = getTwilightContext().page;
      if (page?.slug) sendPageView(page);
    }

    const salla = getSallaSDK();
    if (!salla?.event) return;

    // Every later, client-side navigation fires route::changed with the new page's data.
    const onRouteChanged = (...args: unknown[]) => {
      const page = (args[0] ?? {}) as PageData;
      if (page.slug) sendPageView(page);
    };
    salla.event.on('route::changed', onRouteChanged);
    return () => salla.event.off('route::changed', onRouteChanged);
  }, []);

  return null;
}
  • The first view comes from getTwilightContext().page, the same data the event would have carried.
  • Later views come from route::changed; the listener is removed with the same function in the cleanup.
  • Views without a slug are skipped: the payload is {} when no page data has loaded yet.

3. Mount it once, on every page

app/hooks/index.tsx (excerpt)
import { hookRegistry, HookName } from '@salla.sa/twilight-theme-engine/hooks';
import { PageViewTracker } from '../components/PageViewTracker';

// Inside registerThemeHooks(): body:end is rendered by TwilightProvider on every page and
// stays mounted while the shopper navigates.
hookRegistry.register(HookName.BODY_END, () => <PageViewTracker />);
In engine terms

createRouter (src/tanstack/router.tsx) subscribes to the router's onLoad event, where it copies the deepest match's loaderData.page into the twilight context and into Salla.config (page), but only when some match has one. On onResolved it syncs the location and calls dispatchRouteChanged() (src/utils/sdk-events.ts), which runs Salla.event.dispatch('route::changed', getTwilightContext().page ?? {}) when the SDK exists. Hydration never emits onLoad or onResolved: TanStack's Transitioner only emits onRendered for the location the server resolved, and hydrateTwilightContext sets page from the dehydrated matches instead. The SDK's emitter also pushes { event: name, ...payload } into window.dataLayer for every event it emits, so a Google Tag Manager trigger on the custom event route::changed needs no theme code.

Traps