• Free shipping on orders over €50

Repository markdown

Source: docs/data-layer/README.md

Wiki home

Data Layer Architecture for Single Page Applications

This document describes how the dataLayer is managed in this Next.js SPA — specifically how routing events, referrer persistence, execution order, and ecommerce events are orchestrated. The patterns here are designed to be portable to any SPA framework (React, Vue, Svelte, etc.) and work correctly with Google Tag Manager (GTM).


Table of Contents

  1. Core Principles
  2. Execution Order
  3. Referrer Persistence (Session Storage)
  4. virtual_page_view — The Foundation Event
  5. Event Gating Mechanism
  6. Implementation Files
  7. Adapting to Other Projects

Core Principles

  1. virtual_page_view is always the first event on every page. All other data layer events (ecommerce, interactions) are guaranteed to fire _after_ the routing event. This is critical because GTM tags often depend on page-level context (URL, referrer, title) that is set by this event.
  1. The referrer must reflect the actual previous page, not document.referrer. In an SPA, document.referrer is frozen at the value from the initial full page load and never updates during client-side navigations. On a hard reload it still contains the original external source — not the last internal page the user visited.
  1. New external entries must still be captured. If the user navigates away and returns via a new link/source (e.g. clicks a Google result again), the new document.referrer must be used instead of the stored internal page.
  1. Consent Mode signals must precede all other pushes. The very first push to dataLayer is always gtag('consent', 'default', ...) so GTM and all tags respect the consent state from the start.

Execution Order

The following is the guaranteed order on every page load:

┌─────────────────────────────────────────────────────────────────────┐
│ 1. Inline <script> in <head> (synchronous, blocks HTML parsing)     │
│    ├─ Initialize window.dataLayer                                   │
│    ├─ gtag('consent', 'default', state)   ← from localStorage       │
│    ├─ Resolve referrer via sessionStorage logic                     │
│    ├─ Push { event: 'virtual_page_view', page: {...} }              │
│    └─ Dispatch 'spa:routing_complete' custom event                  │
├─────────────────────────────────────────────────────────────────────┤
│ 2. GTM container script loads (async, but always after #1)          │
│    └─ GTM sees virtual_page_view already in dataLayer               │
├─────────────────────────────────────────────────────────────────────┤
│ 3. React hydrates / mounts                                          │
│    ├─ RoutingCompleteTracker detects URL matches existing push →    │
│    │   skips duplicate                                              │
│    └─ Other tracker components (ViewItemTracker, etc.) fire their   │
│       events — all gated behind routing_complete                    │
├─────────────────────────────────────────────────────────────────────┤
│ 4. Subsequent client-side navigations                               │
│    ├─ RoutingCompleteTracker fires virtual_page_view                │
│    ├─ Updates sessionStorage with current URL                       │
│    └─ Dispatches 'spa:routing_complete' → unblocks gated           │
│       ecommerce events                                              │
└─────────────────────────────────────────────────────────────────────┘

Why this order matters

  • GTM triggers that fire on virtual_page_view will always have access to the correct page.url, page.referrer, and page.title.
  • Tags that read page-level variables from the data layer (e.g. for server-side tagging, attribution, or audience building) are guaranteed to see fresh values.
  • Ecommerce events never race ahead of the page view — there is no scenario where a view_item fires before GTM knows what page the user is on.

Referrer Persistence (Session Storage)

The problem

In a traditional multi-page site, each page load sets a fresh document.referrer. In an SPA:

  • Client-side navigation: document.referrer does not update. It still shows the external source from the initial page load.
  • Hard reload (F5): document.referrer still shows the original external referrer, not the last internal page.
  • New external entry: The user clicks a link from another site — document.referrer finally updates.

The solution

Two keys in sessionStorage:

KeyPurpose
_dl_current_urlThe URL of the last page the user was on. Becomes the referrer for the next page.
_dl_document_referrerThe last observed document.referrer value. Used to detect genuinely new external entries.

Decision logic (runs on every page load)

if (document.referrer changed from stored value AND is not empty)
  → New external entry. Use document.referrer.
else if (stored previous page URL exists)
  → Use stored previous page URL.
else
  → First visit, nothing stored. Fall back to document.referrer.

After resolving:

  • Store current URL → sessionStorage (so the _next_ page can use it as referrer)
  • Store document.referrer → sessionStorage (for change detection)

Why sessionStorage?

  • Survives reloads within the same tab/session.
  • Does NOT persist across tabs or after the tab is closed — this correctly models a "session". A new tab is a new session with a fresh document.referrer.
  • New external entries are detected because document.referrer changes when the user arrives from a different source, even within the same session.

virtual_page_view — The Foundation Event

Schema

json
{
  "event": "virtual_page_view",
  "page": {
    "url": "https://example.com/products/shoes?color=blue",
    "path": "/products/shoes",
    "referrer": "https://example.com/categories/footwear",
    "title": "Blue Running Shoes — Example Store"
  }
}

Where it fires

ScenarioSourceReferrer resolution
Initial page loadInline <script> in <head>sessionStorage logic (see above)
Client-side navigationRoutingCompleteTracker React componentReact useRef tracking previous URL
Hard reloadInline <script> (same as initial)sessionStorage provides correct previous page

Important: no duplicate on hydration

When React hydrates after the initial page load, the RoutingCompleteTracker checks whether the inline script already pushed a virtual_page_view for the current URL. If it did (matched via window.__dlRoutingCompleteUrl), the tracker skips its push to avoid a duplicate.


Event Gating Mechanism

All ecommerce and interaction events are pushed through a pushAfterRoutingComplete() function that:

  1. Checks if window.__dlRoutingCompleteUrl matches the current URL.
  2. If yes → pushes the event immediately (routing already completed).
  3. If no → listens for the spa:routing_complete custom DOM event and pushes once it fires for the matching URL.

This guarantees ordering regardless of component render timing or React Suspense boundaries.


Implementation Files

FileRole
lib/consent/initScript.tsBuilds the synchronous inline script: consent default + referrer resolution + first virtual_page_view
lib/analytics/referrer.tsShared sessionStorage keys and the resolveAndPersistReferrer() / persistCurrentUrl() utilities
lib/analytics/dataLayer.tsAll dataLayer push functions, the routing-complete event system, and the gating mechanism
components/analytics/Trackers.tsxReact components: RoutingCompleteTracker (page views) + specific event trackers (view_item, etc.)
app/layout.tsxRoot layout: places the inline consent/routing script in <head>
app/[locale]/layout.tsxLocale layout: mounts RoutingCompleteTracker and other tracking infrastructure

Adapting to Other Projects

To replicate this pattern in another SPA:

1. Inline script in <head> (framework-agnostic)

Place a synchronous <script> as early as possible in the HTML <head>. It must:

  • Initialize window.dataLayer
  • Push consent defaults
  • Resolve the referrer from sessionStorage
  • Push virtual_page_view
  • Set a flag (window.__dlRoutingCompleteUrl) and dispatch a custom event

2. Route-change listener

In your SPA framework's router (React useEffect on pathname, Vue router.afterEach, etc.):

  • Skip if the inline script already handled this URL
  • Use the previous URL (tracked in a local variable/ref) as the referrer
  • Call persistCurrentUrl() to keep sessionStorage in sync
  • Push virtual_page_view
  • Dispatch the routing-complete event

3. Gate all other events

Wrap every non-routing dataLayer push in a function that waits for routing-complete. This can be:

  • A simple check of the flag variable, or
  • An event listener on the custom DOM event

4. Key considerations

  • The inline script must run before GTM loads.
  • sessionStorage keys should be namespaced to avoid collisions.
  • The routing-complete mechanism should handle URL-specific matching (not just "any route finished") to avoid race conditions with rapid navigation.
  • Always clear ecommerce: null before pushing ecommerce events (GA4 requirement).

The consent integration is tightly coupled with the data layer initialization. See lib/consent/ for the full Consent Mode v2 implementation. The key point for data layer purposes: gtag('consent', 'default', state) is always the very first push, guaranteeing that GTM respects consent from the moment it loads.