Track page views
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:
// 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
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
slugare skipped: the payload is{}when no page data has loaded yet.
3. Mount it once, on every page
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
Go deeper: dispatchRouteChanged and RouteChangedEvent, getTwilightContext, useGtm, and the concept Request lifecycle.