# Data Layer for Single-Page Applications (SPAs) **Source**: https://developers.google.com/analytics/devguides/collection/ga4/single-page-applications **Last Updated**: 2025-01-09 ## Overview Single-Page Applications (SPAs) are websites that load an HTML document once and fetch additional content using JavaScript APIs. Unlike traditional multi-page applications, SPAs require special handling for tracking page views and user interactions because the browser doesn't reload the page when users navigate between different screens. The key challenge with SPAs is that traditional page view tracking relies on full page loads, but in SPAs, navigation happens dynamically without triggering a new page load event. ## Why SPAs Require Special Treatment ### Traditional vs SPA Page Loads **Traditional Website:** - User clicks a link - Browser requests a new HTML document - Page fully reloads - Analytics tags fire automatically on load **Single-Page Application:** - User clicks a link - JavaScript updates the DOM - URL may change (via History API) - No page reload occurs - Analytics tags don't automatically fire ### Key Measurement Goals To accurately track SPAs, you need to: 1. **Count page views for each screen** a user interacts with 2. **Track the page referrer correctly** to trace the user journey 3. **Maintain proper event sequencing** as users navigate 4. **Clear previous page data** to avoid data carryover 5. **Update page-specific parameters** for each virtual page view ## Implementation Methods ### Method 1: Browser History Changes (Recommended) Use this method if your SPA uses the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History), specifically the `pushState()` and `replaceState()` methods to update screens. #### How It Works GTM's **History Change trigger** listens for: - Changes to the URL fragment (hash) - Calls to `history.pushState()` - Calls to `history.replaceState()` - Browser back/forward button clicks (`popstate` event) #### GTM Setup 1. **Enable Built-in History Variables** in GTM: - History Old URL Fragment - History New URL Fragment - History Old State - History New State - History Source (pushState, replaceState, popstate, or hashchange) 2. **Create a History Change Trigger:** - Go to Triggers > New - Choose "History Change" as trigger type - Configure any additional filters if needed - This trigger fires whenever the URL changes without a page reload 3. **Create a Virtual Page View Tag:** - Create a GA4 Event tag - Event Name: `page_view` - Set to fire on the History Change trigger - Configure page parameters (page_location, page_title, etc.) #### Example Implementation ```javascript // Your SPA framework (e.g., React Router) handles navigation // It uses pushState internally: history.pushState({ page: 'products' }, 'Products', '/products'); // GTM's History Change trigger automatically detects this // and fires your configured tags ``` #### Data Layer Push Pattern When using History Change triggers, push updated page data to the data layer: ```javascript // Clear previous page data and push new page data window.dataLayer.push({ event: 'virtual_pageview', page_path: '/new-page', page_title: 'New Page Title', page_location: window.location.href, // Clear previous page-scoped variables previous_page_data: undefined, transaction_id: undefined // Clear transaction-specific data }); ``` ### Method 2: Custom Events Use this method if your website uses the [`DocumentFragment`](https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment) object to render different screens, or if you need more control over when virtual page views fire. #### How It Works You manually push custom events to the data layer when screen changes occur, rather than relying on automatic History API detection. #### Implementation ```javascript // In your SPA's route change handler function onRouteChange(newRoute) { // Update the DOM with new content updateContent(newRoute); // Push virtual pageview to data layer window.dataLayer.push({ event: 'virtual_pageview', page_path: newRoute.path, page_title: newRoute.title, page_location: window.location.href }); } ``` #### GTM Setup 1. **Create a Custom Event Trigger:** - Go to Triggers > New - Choose "Custom Event" as trigger type - Event name: `virtual_pageview` 2. **Create Variables for Page Data:** - Data Layer Variable for `page_path` - Data Layer Variable for `page_title` - Data Layer Variable for `page_location` 3. **Create a GA4 Event Tag:** - Event Name: `page_view` - Add event parameters using the variables created above - Set to fire on the Custom Event trigger ## Data Layer Clearing Best Practices ### Why Clear Data In SPAs, data persists in the data layer until explicitly cleared. This can cause: - Transaction data appearing on non-transaction pages - User data from one session bleeding into another - Incorrect attribution of events to pages ### What to Clear **Page-Scoped Data** (clear on every virtual pageview): - `page_title` - `page_path` - `page_category` - Custom page dimensions **Event-Scoped Data** (clear after the event fires): - `transaction_id` - `ecommerce` objects - Form submission data - Click data **Persistent Data** (keep across page views): - `user_id` - `user_type` - Session-level dimensions ### Clearing Pattern ```javascript // Pattern 1: Clear and set in separate pushes dataLayer.push(function() { this.reset(); // Clear all data layer state }); dataLayer.push({ event: 'virtual_pageview', page_title: 'New Page', page_path: '/new-page' }); // Pattern 2: Clear and set in one push (set to undefined) dataLayer.push({ event: 'virtual_pageview', // Clear previous values transaction_id: undefined, ecommerce: undefined, form_id: undefined, // Set new values page_title: 'New Page', page_path: '/new-page' }); ``` ## Framework-Specific Implementation ### React with React Router ```javascript import { useEffect } from 'react'; import { useLocation } from 'react-router-dom'; function Analytics() { const location = useLocation(); useEffect(() => { // Clear previous page data window.dataLayer.push({ event: 'virtual_pageview', page_path: location.pathname, page_title: document.title, page_location: window.location.href, // Clear event-scoped data transaction_id: undefined, ecommerce: undefined }); }, [location]); return null; } export default Analytics; ``` ### Vue.js with Vue Router ```javascript // In your main.js or router configuration import router from './router'; router.afterEach((to, from) => { window.dataLayer.push({ event: 'virtual_pageview', page_path: to.path, page_title: to.meta.title || document.title, page_location: window.location.href, // Clear event-scoped data transaction_id: undefined, ecommerce: undefined }); }); ``` ### Angular with Angular Router ```typescript import { Router, NavigationEnd } from '@angular/router'; import { filter } from 'rxjs/operators'; export class AppComponent { constructor(private router: Router) { this.router.events.pipe( filter(event => event instanceof NavigationEnd) ).subscribe((event: NavigationEnd) => { window.dataLayer.push({ event: 'virtual_pageview', page_path: event.urlAfterRedirects, page_title: document.title, page_location: window.location.href, // Clear event-scoped data transaction_id: undefined, ecommerce: undefined }); }); } } ``` ### Next.js ```javascript // In pages/_app.js import { useEffect } from 'react'; import { useRouter } from 'next/router'; function MyApp({ Component, pageProps }) { const router = useRouter(); useEffect(() => { const handleRouteChange = (url) => { window.dataLayer.push({ event: 'virtual_pageview', page_path: url, page_title: document.title, page_location: window.location.href, // Clear event-scoped data transaction_id: undefined, ecommerce: undefined }); }; router.events.on('routeChangeComplete', handleRouteChange); return () => { router.events.off('routeChangeComplete', handleRouteChange); }; }, [router.events]); return ; } export default MyApp; ``` ## Testing and Debugging SPAs ### Preview Mode in GTM 1. **Enable Preview Mode** in your GTM container 2. Navigate through your SPA 3. Watch for: - History Change events firing - Custom Event triggers activating - Data layer state changes - Tag firing sequence ### Debug Console Checklist For each virtual page view, verify: - [ ] `page_view` event fires - [ ] `page_location` updates correctly - [ ] `page_title` reflects the new screen - [ ] Previous page's `page_location` becomes current `page_referrer` - [ ] Event-scoped data is cleared - [ ] User/session data persists ### DebugView in GA4 1. **Enable Debug Mode** in GTM tags: ```javascript // Add to your data layer push window.dataLayer.push({ event: 'virtual_pageview', debug_mode: true, // ... other parameters }); ``` 2. **Navigate through your SPA** 3. **Check in GA4 DebugView:** - Each screen change should create a new `page_view` event - Event parameters should update correctly - Event sequence should match user journey ### Common SPA Pitfalls #### 1. Data Carryover **Problem:** Transaction data appears on non-checkout pages **Solution:** ```javascript // Always clear ecommerce data after it's sent dataLayer.push({ event: 'purchase', ecommerce: { /* purchase data */ } }); // On next page view, clear it dataLayer.push({ event: 'virtual_pageview', ecommerce: undefined, // Explicitly clear transaction_id: undefined }); ``` #### 2. Missing Virtual Pageviews **Problem:** Some navigation doesn't trigger page views **Solution:** Ensure all route changes push to data layer, including: - Back/forward button navigation - Hash changes - Modal/dialog state changes (if tracking as pages) #### 3. Duplicate Page Views **Problem:** Both real and virtual page views fire **Solution:** ```javascript // Block the initial page_view from GA4 Config tag // Use a Pageview trigger with "Fire on DOM Ready" exception // Or configure GA4 tag to not send page_view on initial load ``` #### 4. Incorrect Referrer **Problem:** `page_referrer` doesn't reflect previous virtual page **Solution:** GTM automatically handles this when using History Change triggers. For custom events, ensure you're not manually setting `page_referrer`. ## Advanced Patterns ### Conditional Virtual Pageviews Only track certain route changes: ```javascript function shouldTrackPageview(newRoute) { // Don't track hash-only changes if (newRoute.startsWith('#')) return false; // Don't track query parameter-only changes if (isSamePath(currentRoute, newRoute)) return false; return true; } if (shouldTrackPageview(newRoute)) { dataLayer.push({ event: 'virtual_pageview', page_path: newRoute }); } ``` ### Enhanced Virtual Pageview Data Include additional context with virtual pageviews: ```javascript dataLayer.push({ event: 'virtual_pageview', page_path: '/products/category/shoes', page_title: 'Shoes - Products', page_location: window.location.href, // Additional context page_category: 'Products', page_subcategory: 'Shoes', content_group: 'Product Listing', user_journey_step: 'Browse', spa_route_type: 'category_page' }); ``` ## Verification Checklist Before deploying your SPA tracking: - [ ] History Change trigger configured (if using History API) - [ ] Custom Event trigger configured (if using custom events) - [ ] Data Layer Variables created for page parameters - [ ] GA4 Event tag configured with `page_view` event - [ ] Initial page load tracked correctly - [ ] All navigation types trigger virtual pageviews: - [ ] Forward navigation - [ ] Back button - [ ] Direct URL changes - [ ] Hash changes (if applicable) - [ ] Data clearing implemented for: - [ ] Transaction data - [ ] Event-scoped variables - [ ] Page-scoped variables - [ ] Tested in Preview Mode - [ ] Verified in GA4 DebugView - [ ] Event sequence matches user journey - [ ] Page referrer updates correctly ## Resources - [Google Analytics: Measure Single-Page Applications](https://developers.google.com/analytics/devguides/collection/ga4/single-page-applications) - [GTM History Change Trigger](https://support.google.com/tagmanager/answer/7679322) - [GTM Built-in Variables for Web](https://support.google.com/tagmanager/answer/7182738) - [History API Documentation (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/History) - [Single-Page Applications (MDN)](https://developer.mozilla.org/en-US/docs/Glossary/SPA)