Repository markdown
Source: docs/data-layer/README.md
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
- Core Principles
- Execution Order
- Referrer Persistence (Session Storage)
- virtual_page_view — The Foundation Event
- Event Gating Mechanism
- Implementation Files
- Adapting to Other Projects
Core Principles
virtual_page_viewis 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.
- The referrer must reflect the actual previous page, not
document.referrer. In an SPA,document.referreris 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.
- 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.referrermust be used instead of the stored internal page.
- Consent Mode signals must precede all other pushes. The very first push to
dataLayeris alwaysgtag('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_viewwill always have access to the correctpage.url,page.referrer, andpage.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_itemfires 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.referrerdoes not update. It still shows the external source from the initial page load. - Hard reload (F5):
document.referrerstill shows the original external referrer, not the last internal page. - New external entry: The user clicks a link from another site —
document.referrerfinally updates.
The solution
Two keys in sessionStorage:
| Key | Purpose |
|---|---|
_dl_current_url | The URL of the last page the user was on. Becomes the referrer for the next page. |
_dl_document_referrer | The 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.referrerchanges when the user arrives from a different source, even within the same session.
virtual_page_view — The Foundation Event
Schema
{
"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
| Scenario | Source | Referrer resolution |
|---|---|---|
| Initial page load | Inline <script> in <head> | sessionStorage logic (see above) |
| Client-side navigation | RoutingCompleteTracker React component | React useRef tracking previous URL |
| Hard reload | Inline <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:
- Checks if
window.__dlRoutingCompleteUrlmatches the current URL. - If yes → pushes the event immediately (routing already completed).
- If no → listens for the
spa:routing_completecustom 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
| File | Role |
|---|---|
lib/consent/initScript.ts | Builds the synchronous inline script: consent default + referrer resolution + first virtual_page_view |
lib/analytics/referrer.ts | Shared sessionStorage keys and the resolveAndPersistReferrer() / persistCurrentUrl() utilities |
lib/analytics/dataLayer.ts | All dataLayer push functions, the routing-complete event system, and the gating mechanism |
components/analytics/Trackers.tsx | React components: RoutingCompleteTracker (page views) + specific event trackers (view_item, etc.) |
app/layout.tsx | Root layout: places the inline consent/routing script in <head> |
app/[locale]/layout.tsx | Locale 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: nullbefore pushing ecommerce events (GA4 requirement).
Related: Consent Mode
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.