Modern web analytics requires balancing insight with privacy. With regulations like GDPR, you can't just drop a tracking script on your site and call it done.

This guide shows how to implement Google Analytics 4 with Google Tag Manager, including Consent Mode v2 for privacy-compliant tracking that respects user choices.

The Analytics Stack

A modern, privacy-compliant analytics setup has three components:

  1. Google Tag Manager (GTM) - Container for all your tags
  2. Google Analytics 4 (GA4) - The analytics platform
  3. Consent Mode - Signals to control data collection
User Visit → Consent Check → GTM Loads → GA4 Fires (respecting consent)

Without Consent Mode, you have two bad options:

  1. Load analytics always - Violates GDPR, risk of fines
  2. Load analytics only after consent - Lose data from users who don't interact with consent banner

Consent Mode provides a third option:

  1. Load analytics always, but respect consent signals - GA4 adjusts its behavior based on consent state

| Consent State | GA4 Behavior | |---------------|--------------| | granted | Full tracking with cookies | | denied | Cookieless pings, limited data, no user identification |

This means you get some data from all users while fully respecting privacy choices.

Setting Up Google Tag Manager

Step 1: Create GTM Container

  1. Go to tagmanager.google.com
  2. Create Account → Create Container
  3. Choose "Web" as target platform
  4. Copy your GTM ID (format: GTM-XXXXXXX)

Step 2: Install GTM in Next.js

The key insight: GTM must load AFTER consent defaults are set, but BEFORE user interaction.

Here's the implementation pattern:

// src/components/ConditionalScripts.tsx
'use client'

import { useEffect, useState, useRef } from 'react'
import Script from 'next/script'

const GTM_ID = 'GTM-XXXXXXX'

// Helper to push gtag commands to dataLayer
function gtag(...args: unknown[]) {
  window.dataLayer = window.dataLayer || []
  window.dataLayer.push(args)
}

export function ConditionalScripts() {
  const { isLoaded, isGdprRegion } = useConsent()
  const [dataLayerReady, setDataLayerReady] = useState(false)
  const consentDefaultSet = useRef(false)

  // Step 1: Set default consent based on region
  useEffect(() => {
    if (!isLoaded || consentDefaultSet.current) return

    window.dataLayer = window.dataLayer || []

    // GDPR: denied by default, Non-GDPR: granted by default
    const defaultConsent = isGdprRegion ? 'denied' : 'granted'

    gtag('consent', 'default', {
      'analytics_storage': defaultConsent,
      'ad_storage': defaultConsent,
      'ad_user_data': defaultConsent,
      'ad_personalization': defaultConsent,
    })

    consentDefaultSet.current = true
  }, [isLoaded, isGdprRegion])

  // Step 2: Initialize GTM after consent defaults
  useEffect(() => {
    if (!isLoaded || !consentDefaultSet.current) return
    if (dataLayerReady) return

    window.dataLayer = window.dataLayer || []
    window.dataLayer.push({
      'gtm.start': new Date().getTime(),
      event: 'gtm.js',
    })

    setDataLayerReady(true)
  }, [isLoaded, dataLayerReady])

  if (!isLoaded) return null

  return (
    <>
      {dataLayerReady && (
        <Script
          id="gtm-script"
          src={`https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`}
          strategy="afterInteractive"
        />
      )}
    </>
  )
}

The Order Matters

1. Page loads
2. Detect user region (GDPR or not)
3. Push consent defaults to dataLayer
4. Initialize GTM (gtm.start event)
5. Load GTM script
6. GA4 tag fires (respecting consent state)
7. User interacts with consent banner
8. Push consent update to dataLayer
9. GA4 adjusts behavior accordingly

If you load GTM before setting consent defaults, GA4 won't know the initial consent state.

Consent Mode v2 requires four consent signals:

| Signal | Controls | |--------|----------| | analytics_storage | GA4 cookies and data collection | | ad_storage | Advertising cookies | | ad_user_data | Sending user data to Google for ads | | ad_personalization | Personalized advertising |

Setting Defaults by Region

// Determine region (via Vercel headers, GeoIP, etc.)
const isGdprRegion = checkGdprRegion(userCountry)

// Set defaults BEFORE GTM loads
gtag('consent', 'default', {
  'analytics_storage': isGdprRegion ? 'denied' : 'granted',
  'ad_storage': isGdprRegion ? 'denied' : 'granted',
  'ad_user_data': isGdprRegion ? 'denied' : 'granted',
  'ad_personalization': isGdprRegion ? 'denied' : 'granted',
})

GDPR Countries

const GDPR_COUNTRIES = [
  // EU-27
  'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
  'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
  'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
  // EEA + UK + Switzerland
  'NO', 'IS', 'LI', 'GB', 'CH'
]

function isGdprRegion(countryCode: string): boolean {
  return GDPR_COUNTRIES.includes(countryCode.toUpperCase())
}

When a user makes a choice on your consent banner:

function updateConsent(analyticsGranted: boolean, marketingGranted: boolean) {
  gtag('consent', 'update', {
    'analytics_storage': analyticsGranted ? 'granted' : 'denied',
    'ad_storage': marketingGranted ? 'granted' : 'denied',
    'ad_user_data': marketingGranted ? 'granted' : 'denied',
    'ad_personalization': marketingGranted ? 'granted' : 'denied',
  })
}

// In your consent banner
function handleAcceptAll() {
  saveConsent({ analytics: true, marketing: true })
  updateConsent(true, true)
}

function handleRejectAll() {
  saveConsent({ analytics: false, marketing: false })
  updateConsent(false, false)
}

Setting Up GA4 in GTM

Step 1: Create GA4 Property

  1. Go to analytics.google.com
  2. Admin → Create Property
  3. Copy your Measurement ID (format: G-XXXXXXXXXX)

Step 2: Create GA4 Tag in GTM

  1. In GTM, go to Tags → New
  2. Choose "Google Analytics: GA4 Configuration"
  3. Enter your Measurement ID
  4. Trigger: "All Pages"
  1. In your GA4 tag, go to Advanced Settings
  2. Consent Settings → "Require additional consent for tag to fire"
  3. Add: analytics_storage

This tells the tag to check consent before full data collection.

Debugging Your Implementation

GTM Preview Mode

  1. In GTM, click Preview
  2. Enter your site URL
  3. Navigate your site
  4. See which tags fire and when

GA4 DebugView

  1. In GA4, go to Admin → DebugView
  2. Install Google Analytics Debugger extension
  3. Browse your site
  4. See events in real-time

Console Logging

Add logging to verify consent flow:

gtag('consent', 'default', {
  'analytics_storage': defaultConsent,
  // ...
})
console.log('Consent default set:', defaultConsent)

// On GTM load
<Script
  onLoad={() => console.log('GTM loaded successfully')}
  onError={(e) => console.error('GTM failed:', e)}
/>

Check dataLayer

In browser console:

// See all dataLayer events
console.log(window.dataLayer)

// Check consent state
window.dataLayer.filter(item => item[0] === 'consent')

Common Issues and Solutions

Issue: No Location Data in GA4

Symptom: Page views appear but geographic data is missing.

Cause: Consent mode with denied state doesn't collect IP-based location.

Solution: Ensure non-GDPR regions get granted by default. Location data only appears when analytics_storage is granted.

Issue: GTM Loads But GA4 Doesn't Fire

Symptom: GTM loads, but no GA4 events appear.

Cause: Consent defaults not set before GTM loads.

Solution: Push consent defaults BEFORE the gtm.start event:

// 1. First: consent defaults
gtag('consent', 'default', {...})

// 2. Then: GTM start
window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' })

// 3. Finally: load script
<Script src="...gtm.js" />

Symptom: User accepts cookies but tracking doesn't change.

Cause: Using wrong syntax for consent update.

Solution: Use array syntax for gtag:

// Correct - array format
function gtag(...args: unknown[]) {
  window.dataLayer.push(args)  // Push as array
}
gtag('consent', 'update', {...})

// Results in: ['consent', 'update', {...}]

Issue: Duplicate Events

Symptom: Same event fires multiple times.

Cause: GTM script loading multiple times or React re-renders.

Solution: Use refs to track initialization:

const initialized = useRef(false)

useEffect(() => {
  if (initialized.current) return
  // ... initialization code
  initialized.current = true
}, [])

Privacy-First Architecture

Here's the complete consent-aware architecture:

┌──────────────────────────────────────────────────────────┐
│                      User Visit                           │
└─────────────────────────┬────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│              Detect Region (Vercel Headers)               │
│         Is GDPR? → Default: denied                        │
│         Not GDPR? → Default: granted                      │
└─────────────────────────┬────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│            Push Consent Defaults to dataLayer             │
│     gtag('consent', 'default', { analytics_storage })     │
└─────────────────────────┬────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│                    Load GTM Script                        │
│           GA4 tag fires (respecting consent)              │
└─────────────────────────┬────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│               Show Consent Banner (if GDPR)               │
│         User clicks Accept/Reject/Customize               │
└─────────────────────────┬────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│            Push Consent Update to dataLayer               │
│     gtag('consent', 'update', { analytics_storage })      │
│              GA4 adjusts data collection                  │
└──────────────────────────────────────────────────────────┘

Key Metrics to Track in GA4

Once tracking is working, focus on these metrics:

Acquisition

  • Users - Unique visitors
  • Sessions - Total visits
  • Traffic sources - Where users come from

Engagement

  • Engagement rate - % of engaged sessions
  • Average engagement time - Time on site
  • Pages per session - Content consumption

Behavior

  • Page views - Most popular content
  • Events - User interactions
  • Conversions - Goal completions

Technical

  • Core Web Vitals - Performance via web-vitals library
  • Device/Browser - Technical distribution
  • Geography - Where users are located

Connecting the Pieces

Analytics doesn't exist in isolation:

Summary

Implementing GA4 with Consent Mode requires:

  1. Detect region before anything else
  2. Set consent defaults based on region
  3. Load GTM after defaults are set
  4. Update consent when users make choices
  5. Debug thoroughly with Preview mode and DebugView

The order matters. Get it wrong, and you'll either violate privacy regulations or lose valuable data.

Get it right, and you have a privacy-compliant analytics setup that provides insights while respecting user choices.


This post is part of the Web Optimization series. For the complete picture, see our Complete Guide to Web Optimization.