Overview
Set up GA4 via GTM with a custom cookie consent banner to track key conversions (contact form, appointment booking), understand traffic sources, and measure content performance — fully GDPR-compliant for EU markets (NL/DE/EN).
Pre-requisites (Manual Steps)
Before any code work:
- Create a GA4 property at analytics.google.com → Admin → Create → Property (timezone: Amsterdam, currency: EUR). Add a Web data stream. Copy the Measurement ID (
G-XXXXXXXXXX).
- Create a GTM container at tagmanager.google.com → Create Account → Container (Web). Copy the Container ID (
GTM-XXXXXXX).
- Configure GTM container (after code is deployed):
- Enable Consent Overview under Admin → Container Settings
- Create a Google tag (Measurement ID) firing on All Pages; require
analytics_storage consent
- Create Data Layer Variables:
page_locale, service_selected, appointment_date
- Create Custom Event triggers:
CE - contact_form_submitted, CE - appointment_booked
- Create GA4 Event tags for each trigger with corresponding parameters
- Publish the container
- Add
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX to .env.local and production environment
Implementation Tasks
Task 1 — Core Analytics Files (New)
/frontend/lib/analytics-constants.ts — centralize event names, consent storage key, and consent values as named constants (no magic strings)
/frontend/types/analytics.d.ts — augment global Window type with dataLayer and gtag
/frontend/lib/analytics.ts — all dataLayer.push calls centralized here:
trackContactFormSubmitted({ locale, serviceSelected })
trackAppointmentBooked({ locale, serviceSelected, appointmentDate }) — date only (YYYY-MM-DD), no time
trackCtaClicked({ locale, ctaLabel, ctaLocation })
updateConsentState(granted) — pushes consent update + calls gtag('consent', 'update', ...)
- All tracking functions guard on
hasAnalyticsConsent() before pushing
/frontend/lib/hooks/useConsent.ts — client hook managing consent state via localStorage with cross-tab sync via storage events
Task 2 — CSP Update
File: /frontend/proxy.ts
Update three CSP directives:
script-src: add https://www.googletagmanager.com
connect-src: add https://www.google-analytics.com https://analytics.google.com
frame-src: add https://www.googletagmanager.com
img-src: add https://www.google-analytics.com
Task 3 — GTM Components (New Server Components)
/frontend/components/analytics/GtmScript.tsx — Server Component that reads nonce from headers(), renders inline <script nonce={nonce}> setting Consent Mode v2 defaults (all denied), then <Script strategy="afterInteractive" nonce={nonce}> for the GTM loader. Returns null if NEXT_PUBLIC_GTM_ID not set.
/frontend/components/analytics/GtmNoScript.tsx — Server Component rendering the <noscript><iframe> fallback (first child of <body>)
Task 4 — Cookie Consent Banner
/frontend/components/ui/CookieConsentBanner.tsx — Client Component using useConsent hook and useTranslations('cookieConsent'). Fixed bottom bar with Decline + Accept buttons. Renders null if consent is already resolved.
/frontend/messages/nl.json, en.json, de.json — add "cookieConsent" i18n namespace with keys: dialogLabel, message, privacyLink, accept, decline
Task 5 — Layout Integration
File: /frontend/app/[locale]/layout.tsx
<html ...>
<body className="antialiased">
<GtmNoScript />
<GtmScript />
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<CookieConsentBanner />
</NextIntlClientProvider>
</body>
</html>
Task 6 — Conversion Tracking
/frontend/components/ui/ContactForm.tsx — add useLocale + call trackContactFormSubmitted({ locale, serviceSelected: formData.service }) after setSubmitted(true) on the success path
/frontend/components/ui/AppointmentForm.tsx — call trackAppointmentBooked({ locale, serviceSelected, appointmentDate: selectedDatetime.split('T')[0] }) after setSubmitted(true) on the success path
Task 7 — CTA Click Tracking
/frontend/components/analytics/TrackedCtaButton.tsx — thin Client Component wrapper around Button accepting ctaLabel and ctaLocation props, calls trackCtaClicked on click
- Update primary CTA buttons in
Hero.tsx and LandingCta.tsx to use TrackedCtaButton
Task 8 — Tests
File: /frontend/lib/__tests__/analytics.test.ts
Following naming convention {MethodName}_Should{Action}_When{Condition}:
trackContactFormSubmitted_ShouldPushEvent_WhenConsentIsAccepted
trackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsDeclined
trackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsPending
trackAppointmentBooked_ShouldPushEventWithDate_WhenConsentIsAccepted
trackAppointmentBooked_ShouldNotIncludePII_WhenConsentIsAccepted
hasAnalyticsConsent_ShouldReturnTrue_WhenLocalStorageValueIsAccepted
hasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageValueIsDeclined
hasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageIsEmpty
updateConsentState_ShouldPushConsentUpdateEvent_WhenGrantedIsTrue
updateConsentState_ShouldCallGtagConsentUpdate_WhenGtagIsPresent
Event Schemas (No PII)
| Event |
Properties |
contact_form_submitted |
page_locale, service_selected |
appointment_booked |
page_locale, service_selected, appointment_date (YYYY-MM-DD only) |
cta_clicked |
page_locale, cta_label, cta_location |
Verification Checklist
Overview
Set up GA4 via GTM with a custom cookie consent banner to track key conversions (contact form, appointment booking), understand traffic sources, and measure content performance — fully GDPR-compliant for EU markets (NL/DE/EN).
Pre-requisites (Manual Steps)
Before any code work:
G-XXXXXXXXXX).GTM-XXXXXXX).analytics_storageconsentpage_locale,service_selected,appointment_dateCE - contact_form_submitted,CE - appointment_bookedNEXT_PUBLIC_GTM_ID=GTM-XXXXXXXto.env.localand production environmentImplementation Tasks
Task 1 — Core Analytics Files (New)
/frontend/lib/analytics-constants.ts— centralize event names, consent storage key, and consent values as named constants (no magic strings)/frontend/types/analytics.d.ts— augment globalWindowtype withdataLayerandgtag/frontend/lib/analytics.ts— alldataLayer.pushcalls centralized here:trackContactFormSubmitted({ locale, serviceSelected })trackAppointmentBooked({ locale, serviceSelected, appointmentDate })— date only (YYYY-MM-DD), no timetrackCtaClicked({ locale, ctaLabel, ctaLocation })updateConsentState(granted)— pushes consent update + callsgtag('consent', 'update', ...)hasAnalyticsConsent()before pushing/frontend/lib/hooks/useConsent.ts— client hook managing consent state vialocalStoragewith cross-tab sync viastorageeventsTask 2 — CSP Update
File:
/frontend/proxy.tsUpdate three CSP directives:
script-src: addhttps://www.googletagmanager.comconnect-src: addhttps://www.google-analytics.com https://analytics.google.comframe-src: addhttps://www.googletagmanager.comimg-src: addhttps://www.google-analytics.comTask 3 — GTM Components (New Server Components)
/frontend/components/analytics/GtmScript.tsx— Server Component that reads nonce fromheaders(), renders inline<script nonce={nonce}>setting Consent Mode v2 defaults (all denied), then<Script strategy="afterInteractive" nonce={nonce}>for the GTM loader. ReturnsnullifNEXT_PUBLIC_GTM_IDnot set./frontend/components/analytics/GtmNoScript.tsx— Server Component rendering the<noscript><iframe>fallback (first child of<body>)Task 4 — Cookie Consent Banner
/frontend/components/ui/CookieConsentBanner.tsx— Client Component usinguseConsenthook anduseTranslations('cookieConsent'). Fixed bottom bar with Decline + Accept buttons. Rendersnullif consent is already resolved./frontend/messages/nl.json,en.json,de.json— add"cookieConsent"i18n namespace with keys:dialogLabel,message,privacyLink,accept,declineTask 5 — Layout Integration
File:
/frontend/app/[locale]/layout.tsxTask 6 — Conversion Tracking
/frontend/components/ui/ContactForm.tsx— adduseLocale+ calltrackContactFormSubmitted({ locale, serviceSelected: formData.service })aftersetSubmitted(true)on the success path/frontend/components/ui/AppointmentForm.tsx— calltrackAppointmentBooked({ locale, serviceSelected, appointmentDate: selectedDatetime.split('T')[0] })aftersetSubmitted(true)on the success pathTask 7 — CTA Click Tracking
/frontend/components/analytics/TrackedCtaButton.tsx— thin Client Component wrapper aroundButtonacceptingctaLabelandctaLocationprops, callstrackCtaClickedon clickHero.tsxandLandingCta.tsxto useTrackedCtaButtonTask 8 — Tests
File:
/frontend/lib/__tests__/analytics.test.tsFollowing naming convention
{MethodName}_Should{Action}_When{Condition}:trackContactFormSubmitted_ShouldPushEvent_WhenConsentIsAcceptedtrackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsDeclinedtrackContactFormSubmitted_ShouldNotPushEvent_WhenConsentIsPendingtrackAppointmentBooked_ShouldPushEventWithDate_WhenConsentIsAcceptedtrackAppointmentBooked_ShouldNotIncludePII_WhenConsentIsAcceptedhasAnalyticsConsent_ShouldReturnTrue_WhenLocalStorageValueIsAcceptedhasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageValueIsDeclinedhasAnalyticsConsent_ShouldReturnFalse_WhenLocalStorageIsEmptyupdateConsentState_ShouldPushConsentUpdateEvent_WhenGrantedIsTrueupdateConsentState_ShouldCallGtagConsentUpdate_WhenGtagIsPresentEvent Schemas (No PII)
contact_form_submittedpage_locale,service_selectedappointment_bookedpage_locale,service_selected,appointment_date(YYYY-MM-DD only)cta_clickedpage_locale,cta_label,cta_locationVerification Checklist
pnpm typecheck— no TS errorspnpm test— all unit tests passgtm.jsfires on load; no conversion events fire before consentconsent_updatefires; after form submit: correct events fire with no PII/nl/,/en/,/de/) show correctpage_localein events