This isn't another tutorial on how to install Google Analytics. This is documentation of the entire process I went through building a production tracking stack on milanpavlak.sk — from the first command in Google Cloud Shell to the first deduplicated Lead event in Meta Events Manager.
If you just want the result without the context, jump straight to the architecture section. If you want to understand why each decision was made, read from the beginning.
Why Server-Side Tracking
Classic client-side tracking scripts run in the visitor's browser. That makes them vulnerable to:
- Ad blockers — block Meta Pixel and Google Analytics for 20–40% of users
- Safari ITP (Intelligent Tracking Prevention) — limits cookie lifetime to 7 days
- iOS App Tracking Transparency — iOS 14+ significantly reduced the effectiveness of client-side attribution
Server-side tracking solves these problems by sending data directly from the server to analytics platforms — the browser doesn't know it's happening, and ad blockers have nothing to block.
For anyone running ads and optimising campaigns on conversion data, server-side tracking is practically a necessity at this point.
Stack Architecture
Visitor (browser)
↓ consent granted (Cookiebot → GTM)
↓ generates event_id client-side
├── Meta Pixel (via GTM Web Container)
│ ↓ cookie_consent_update trigger
│ → Meta (browser events)
│
├── GA4 Tag (via GTM Web Container)
│ ↓ transport_url = metrics.milanpavlak.sk
│ → sGTM Server Container
│ ├── GA4 Tag → Google Analytics
│ └── Meta CAPI Tag → Meta (server events)
│
└── Next.js Route Handler (/api/capi)
→ Meta Graph API (server events with hashed PII)
A single hit from the browser travels through sGTM and simultaneously through a direct Route Handler on the server — each with the same event_id for correct deduplication.
The Stack
| Layer | Tool | Cost |
|---|---|---|
| Cookie consent | Cookiebot (Usercentrics) | Paid |
| Browser tag management | GTM Web Container | Free |
| Server tag management | GTM Server Container | Free |
| sGTM hosting | Google Cloud Run | Free (free tier) |
| GA4 | Google Analytics 4 | Free |
| Meta Pixel + CAPI | Meta Events Manager | Free |
| Framework | Next.js 15 App Router | Free |
| Hosting | Vercel | Free (free tier) |
Total monthly cost: €0 (at personal site traffic levels)
What sGTM Is and Why You Need It
Standard GTM runs in the visitor's browser. sGTM (server-side Google Tag Manager) runs on a server you control — in this case Google Cloud Run.
Advantages:
- GA4 data goes through your server, not directly to Google — first-party cookies, better resilience against ad blockers
- A single server container can send data to GA4, Meta CAPI, Google Ads, TikTok — from one place
- Full control over exactly what gets sent
--min-instances 0 means the container shuts down when no one is visiting — you pay nothing for idle time. For a personal site, you're well within the free tier limit (2 million requests per month at no cost).
Phase 1 — Google Cloud Platform
Creating the Project
- Go to console.cloud.google.com
- Create a new project — I named mine
milan-sgtm - Connect a billing account (card required, but you won't be charged at low traffic)
- Set a billing alert — no surprises on the invoice
gcloud billing budgets create \
--billing-account=$(gcloud billing projects describe milan-sgtm \
--format="value(billingAccountName)" | sed 's/billingAccounts\///') \
--display-name="milan-sgtm budget alert" \
--budget-amount=5EUR \
--threshold-rule=percent=0.5 \
--threshold-rule=percent=1.0
Phase 2 — GTM Server Container + Cloud Run
Creating the Server Container in GTM
- Go to tagmanager.google.com
- Create a new container → Target platform: Server
- Choose Manually provision tagging server
- Save your Container Config string — you'll need it shortly
Deploying to Google Cloud Run
Open Cloud Shell in the GCP console and run:
gcloud config set project milan-sgtm
# Main container
gcloud run deploy server-side-tagging \
--region europe-west1 \
--image gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable \
--platform managed \
--allow-unauthenticated \
--min-instances 0 \
--max-instances 1 \
--timeout 60 \
--update-env-vars CONTAINER_CONFIG="YOUR_CONTAINER_CONFIG_STRING"
# Preview container (required for GTM debug mode)
gcloud run deploy server-side-tagging-preview \
--region europe-west1 \
--image gcr.io/cloud-tagging-10302018/gtm-cloud-image:stable \
--platform managed \
--allow-unauthenticated \
--min-instances 0 \
--max-instances 1 \
--timeout 60 \
--update-env-vars CONTAINER_CONFIG="YOUR_CONTAINER_CONFIG_STRING",RUN_AS_PREVIEW_SERVER=true
# Link both containers
gcloud run services update server-side-tagging \
--region europe-west1 \
--update-env-vars PREVIEW_SERVER_URL="https://server-side-tagging-preview-XXXXX.europe-west1.run.app"
An HTTP 400 when opening the URL in a browser is the correct response — the server is running, it just doesn't know what to do with a regular browser request.
Custom Subdomain
Add a CNAME record in DNS:
metrics.yourdomain.com → ghs.googlehosted.com
In Cloud Run → Domain mappings, configure your subdomain and wait for propagation (anywhere from 2 minutes to 24 hours).
Phase 3 — GTM Server Container Configuration
GA4 Client
In the server container → Clients → New → Google Analytics: GA4 (Web)
Settings:
- Default GA4 paths: checked
- Cookies and Client Identification: Server Managed
- Domain:
yourdomain.com
GA4 Tag (server-side)
Tags → New → Google Analytics: GA4
- Measurement ID:
G-XXXXXXXX - Redact visitor IP address: True (important for GDPR)
- Trigger: All Pages
Meta CAPI Tag
Import the template from Community Template Gallery: Facebook Conversion API by stape-io
Settings:
- Event Name Setup Method: Inherit from client
- Action Source: Website
- API Access Token: from Meta Events Manager
- Facebook Pixel ID: your Pixel ID
- Generate _fbp cookie if it does not exist: checked
- Enable Event Enhancement: checked
- Tag Execution Consent Settings: Send data in case marketing consent given
Phase 4 — GTM Web Container
Updating the Google Tag
Add these parameters to your existing Google Tag:
transport_url → https://metrics.yourdomain.com
server_container_url → https://metrics.yourdomain.com
This is the critical step — without transport_url, GA4 still goes directly to Google instead of routing through your sGTM.
Consent Mode v2 — What I Learned About Cookiebot
The Cookiebot GTM template with an empty Default Consent State table is correct — an empty table means denied globally for everyone. Nothing needs to be added.
What should be checked:
- Enable Google Consent Mode: ✓
- Enable URL passthrough: ✓
- Advertiser Consent Mode: ✓
Trigger: Consent Initialization — All Pages (not All Pages — there's a significant difference in timing)
Meta Pixel Tags and Their Triggers
Key insight: Meta Pixel tags must not have an All Pages trigger. They need to wait for user consent.
Correct trigger for Meta Pixel tags: cookie_consent_update (Cookiebot creates this automatically)
Every Meta Pixel tag must have:
- Consent Settings → Require additional consent:
ad_storage - Tag sequencing → fires AFTER the Meta Pixel Base tag
Tag List
Web Container:
| Tag | Type | Trigger |
|---|---|---|
| Meta Pixel - Base | Custom HTML | cookie_consent_update |
| Meta Pixel - PageView | Custom HTML | cookie_consent_update |
| Meta Pixel - ViewContent | Custom HTML | PV - Services Pages |
| Meta Pixel - Lead - Contact | Custom HTML | CE - Form Submit Contact |
| Meta Pixel - Lead - Pricing | Custom HTML | CE - Form Submit Pricing |
| GA4 - ViewContent | GA4 Event | PV - Services Pages |
| GA4 - Form Submit Contact | GA4 Event | CE - Form Submit Contact |
| GA4 - Form Submit Pricing | GA4 Event | CE - Form Submit Pricing |
| GA4 - Scroll Depth | GA4 Event | SD - Scroll Depth |
Important: Enable built-in variables — Scroll Depth Threshold, Scroll Depth Units, Scroll Direction, Page Path, Page URL. Without these you'll get "Unknown variable" errors.
Phase 5 — Next.js Code
Environment Variables
NEXT_PUBLIC_GTM_ID=GTM-XXXXXXX
META_PIXEL_ID=your_pixel_id
META_ACCESS_TOKEN=your_access_token
META_API_VERSION=v22.0
META_TEST_EVENT_CODE= # empty in production
Consent Defaults in layout.tsx
This must come before the GTM script — otherwise tags fire before the default consent state is set:
<script
data-cookieconsent="ignore"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag("consent","default",{
ad_user_data:"denied",
ad_personalization:"denied",
ad_storage:"denied",
analytics_storage:"denied",
functionality_storage:"denied",
personalization_storage:"denied",
security_storage:"granted",
wait_for_update:500
});
gtag("set","ads_data_redaction",true);
gtag("set","url_passthrough",true);
`,
}}
/>
The GTM script must use strategy="afterInteractive", not strategy="lazyOnload".
_fbevt Session Cookie
The key element for deduplication event IDs. A server-side Route Handler sets a 32-character hex token:
// app/api/fbevt/route.ts
import crypto from 'crypto'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET() {
const cookieStore = await cookies()
const existing = cookieStore.get('_fbevt')
if (existing) {
return NextResponse.json({ ok: true })
}
const token = crypto.randomBytes(16).toString('hex')
const response = NextResponse.json({ ok: true })
response.cookies.set('_fbevt', token, {
httpOnly: false, // must be readable in the browser for event_id assembly
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 3600,
})
return response
}
event_id Format
EventName_fbevtToken_pageLoadId
example: Lead_contact_db1456f37a2da2e95957e366050396ef_8qjw3p1ba
This is the most important part of deduplication — browser and server must send identical event IDs for the same action.
Meta CAPI Route Handler
// app/api/capi/route.ts
import crypto from 'crypto'
import { cookies } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
function sha256(val: string): string {
return crypto.createHash('sha256').update(val).digest('hex')
}
export async function POST(req: NextRequest) {
const ua = req.headers.get('user-agent') ?? ''
// Bot filtering
if (/bot|crawl|spider|slurp|googlebot/i.test(ua)) {
return NextResponse.json({ ok: false })
}
const body = await req.json()
const { event_name, event_id, page_url, custom_data,
email, phone, first_name, last_name } = body
if (!event_name || !event_id) {
return NextResponse.json({ ok: false }, { status: 400 })
}
const cookieStore = await cookies()
const fbevt = cookieStore.get('_fbevt')?.value
const fbp = cookieStore.get('_fbp')?.value
const fbc = cookieStore.get('_fbc')?.value
const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ?? ''
const user_data: Record<string, unknown> = {
client_ip_address: ip,
client_user_agent: ua,
...(fbp && { fbp }),
...(fbc && { fbc }),
...(fbevt && { external_id: [sha256(fbevt)] }),
...(email && { em: [sha256(email.toLowerCase().trim())] }),
...(phone && { ph: [sha256(phone.replace(/[^0-9+]/g, ''))] }),
...(first_name && { fn: [sha256(first_name.toLowerCase().trim())] }),
...(last_name && { ln: [sha256(last_name.toLowerCase().trim())] }),
country: [sha256('sk')],
}
const payload = {
data: [{
event_name,
event_time: Math.floor(Date.now() / 1000),
event_id,
event_source_url: page_url,
action_source: 'website',
user_data,
...(custom_data && { custom_data }),
}],
...(process.env.META_TEST_EVENT_CODE && {
test_event_code: process.env.META_TEST_EVENT_CODE
}),
}
await fetch(
`https://graph.facebook.com/${process.env.META_API_VERSION}/${process.env.META_PIXEL_ID}/events?access_token=${process.env.META_ACCESS_TOKEN}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
)
return NextResponse.json({ ok: true })
}
TrackingProvider Component
A client component that wraps the app — initialises cookies, watches pathname changes, and sends ViewContent and PageView CAPI events:
// components/TrackingProvider.tsx
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { hasMarketingConsent } from '@/lib/tracking/consent'
import { generateEventId } from '@/lib/tracking/eventId'
import { sendCapiEvent } from '@/lib/tracking/capi'
export function TrackingProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
useEffect(() => {
fetch('/api/fbevt').catch(() => {})
if (!window._fbPageId) {
window._fbPageId = Math.random().toString(36).substr(2, 9)
}
}, [])
useEffect(() => {
window._fbPageId = Math.random().toString(36).substr(2, 9)
// PageView CAPI — delay needed for a stable _fbPageId before generating the event_id
setTimeout(() => {
if (hasMarketingConsent()) {
sendCapiEvent({
event_name: 'PageView',
event_id: generateEventId('PageView'),
page_url: window.location.href,
})
}
}, 100)
// ViewContent on service pages
if (pathname?.includes('/services') && hasMarketingConsent()) {
const eventId = generateEventId('ViewContent')
sendCapiEvent({
event_name: 'ViewContent',
event_id: eventId,
page_url: window.location.href,
custom_data: {
content_name: pathname,
content_category: 'services',
},
})
}
}, [pathname])
return <>{children}</>
}
Form Instrumentation
On each form after a successful submission:
import { generateEventId } from '@/lib/tracking/eventId'
import { trackFormSubmit } from '@/lib/tracking/ga4'
import { sendCapiEvent } from '@/lib/tracking/capi'
import { hasMarketingConsent } from '@/lib/tracking/consent'
// After successful submission
const eventId = generateEventId('Lead_contact')
// Pushes to dataLayer → GTM fires the Meta Pixel Lead tag + GA4 generate_lead
trackFormSubmit('contact', eventId)
// Direct CAPI with hashed PII — only if consent was granted
if (hasMarketingConsent()) {
sendCapiEvent({
event_name: 'Lead',
event_id: eventId,
page_url: window.location.href,
custom_data: {
content_name: 'Contact',
content_category: 'form_submission',
},
email: formData.email,
first_name: formData.firstName,
last_name: formData.lastName,
})
}
Problems I Hit Along the Way
Content Security Policy Was Blocking Everything
After the first deploy, Meta Pixel wasn't working — CSP was blocking both connect.facebook.net and the sGTM subdomain. Fix: add the missing domains to CSP in next.config.ts:
script-src: + https://connect.facebook.net
connect-src: + https://metrics.yourdomain.com https://connect.facebook.net
https://www.facebook.com https://graph.facebook.com
frame-src: + https://metrics.yourdomain.com https://www.facebook.com
img-src: + https://www.facebook.com
CSP is the first thing to check when something inexplicably stops working after a deploy.
Event ID Mismatch in Deduplication
Browser: Lead_contact_618e1f..._y1yf4evml
Server: Lead_618e1f..._y1yf4evml
The GTM Meta Pixel Lead tag was generating an event_id with the prefix Lead_contact_, while the CAPI Route Handler was only generating Lead_. Fix: synchronise the format in the Next.js code:
// Wrong
const eventId = generateEventId('Lead')
// Correct — must match the format used in the GTM tag
const eventId = generateEventId('Lead_contact') // for the contact form
const eventId = generateEventId('Lead_pricing') // for the pricing form
window._fbPageId Race Condition
The PageView CAPI event was generating an event_id before the Meta Pixel Base tag had a chance to set _fbPageId — result: nopid in the event_id and broken deduplication. Fix: 100ms delay before generating the PageView event_id.
GTM Built-In Variables Were Not Enabled
Error: Unknown variable "Scroll Depth Threshold" and Unknown variable "Page Title". Fix: GTM → Variables → Configure Built-In Variables → enable the Scroll Depth group and the Pages group.
Meta Pixel Script Blocked by CSP
The first test showed fbevents.js couldn't load. Cause: CSP didn't include connect.facebook.net in script-src. Added it to connect-src and frame-src as well.
Verification
Meta Events Manager — Test Events
- Go to Events Manager → your Pixel → Test Events
- Get a Test Event Code (e.g.
TEST19036) - Set in Vercel env vars:
META_TEST_EVENT_CODE=TEST19036 - Redeploy and visit the site
What to look for:
- Browser events: PageView, ViewContent
- Server events: ViewContent (from the CAPI Route Handler), PageView
- Deduplicated badge on server events — that's confirmation the system is working
sGTM Server Container Preview
Order matters here:
- Open the Server Container Preview first — click Preview in the server container
- Open the Web Container Preview in the same browser
- Visit the site
- Return to the Server Container Preview
What you should see:
collect?v=2&tid=G-XXXXXXXXrequests arriving at sGTM- GA4 - Server Side: Fired X times ✓
- Meta CAPI - Server Side: Fired X times ✓
- Tags not fired: None ✓
Results
| What | Status |
|---|---|
| GA4 client-side | ✅ |
| GA4 server-side via sGTM | ✅ |
| Meta Pixel browser | ✅ |
| Meta CAPI server | ✅ |
| Deduplication | ✅ |
| Consent Mode v2 (Advanced) | ✅ |
| Form tracking with hashed PII | ✅ |
| Scroll depth 25/50/75/90% | ✅ |
| ViewContent on service pages | ✅ |
| GDPR compliance | ✅ |
User data keys in Meta Events Manager after form submission:
- Country ✓
- External ID (sha256 of session token) ✓
- Browser ID (_fbp) ✓
- IP address ✓
- User agent ✓
- Email (sha256) ✓
- First name (sha256) ✓
- Last name (sha256) ✓
What I Left Out (For Later)
Google Ads Enhanced Conversions — sends hashed email/phone to Google Ads for better attribution. Requires active Google Ads conversions and is a separate GTM setup. It makes sense to tackle this when a Google Ads campaign is actually running.
Summary
The full stack runs on Vercel free tier + Google Cloud Run free tier — for a personal site the monthly cost is €0.
Implementation time: one long day from zero to a fully working production setup.
Key takeaways:
- CSP is the first thing to check when something isn't working
- The event_id format must be identical on browser and server — every character
- The Cookiebot GTM template with an empty consent table is the correct configuration
- sGTM Preview requires the right order — open the server container first
--min-instances 0is fine for a personal site; use--min-instances 1for production clients running active ad campaigns
If you're working on a similar setup or have questions, feel free to reach out — the contact form on this site works and tracks conversions. I know.