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:
- Google Tag Manager (GTM) - Container for all your tags
- Google Analytics 4 (GA4) - The analytics platform
- Consent Mode - Signals to control data collection
User Visit → Consent Check → GTM Loads → GA4 Fires (respecting consent)
Why Consent Mode Matters
Without Consent Mode, you have two bad options:
- Load analytics always - Violates GDPR, risk of fines
- Load analytics only after consent - Lose data from users who don't interact with consent banner
Consent Mode provides a third option:
- 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
- Go to tagmanager.google.com
- Create Account → Create Container
- Choose "Web" as target platform
- 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.
Implementing Consent Mode v2
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())
}
Updating Consent
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
- Go to analytics.google.com
- Admin → Create Property
- Copy your Measurement ID (format:
G-XXXXXXXXXX)
Step 2: Create GA4 Tag in GTM
- In GTM, go to Tags → New
- Choose "Google Analytics: GA4 Configuration"
- Enter your Measurement ID
- Trigger: "All Pages"
Step 3: Enable Consent Mode in Tag
- In your GA4 tag, go to Advanced Settings
- Consent Settings → "Require additional consent for tag to fire"
- Add:
analytics_storage
This tells the tag to check consent before full data collection.
Debugging Your Implementation
GTM Preview Mode
- In GTM, click Preview
- Enter your site URL
- Navigate your site
- See which tags fire and when
GA4 DebugView
- In GA4, go to Admin → DebugView
- Install Google Analytics Debugger extension
- Browse your site
- 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" />
Issue: Consent Update Not Working
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-vitalslibrary - Device/Browser - Technical distribution
- Geography - Where users are located
Connecting the Pieces
Analytics doesn't exist in isolation:
- SEO impact: Track organic traffic to see if technical SEO changes work
- Performance monitoring: Send Core Web Vitals to GA4
- Content validation: See which content with rich results performs best
Summary
Implementing GA4 with Consent Mode requires:
- Detect region before anything else
- Set consent defaults based on region
- Load GTM after defaults are set
- Update consent when users make choices
- 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.