Server-Side TrackingMeta CAPIsGTMGoogle Tag ManagerNext.jsGA4Consent Mode v2CookiebotGoogle Cloud Run

Server-Side Tracking on Next.js: Meta CAPI, sGTM and Consent Mode v2 – A Complete Guide

How I built a complete server-side tracking stack on Next.js 15 with Meta Conversions API, Google Tag Manager server container, GA4 and Consent Mode v2. Everything from scratch — including the mistakes and how I fixed them.

Milan PavlákMilan Pavlák
14 min read
Server-Side Tracking on Next.js: Meta CAPI, sGTM and Consent Mode v2 – A Complete Guide

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

  1. Go to console.cloud.google.com
  2. Create a new project — I named mine milan-sgtm
  3. Connect a billing account (card required, but you won't be charged at low traffic)
  4. 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

  1. Go to tagmanager.google.com
  2. Create a new container → Target platform: Server
  3. Choose Manually provision tagging server
  4. 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.

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

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".

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

  1. Go to Events Manager → your Pixel → Test Events
  2. Get a Test Event Code (e.g. TEST19036)
  3. Set in Vercel env vars: META_TEST_EVENT_CODE=TEST19036
  4. 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:

  1. Open the Server Container Preview first — click Preview in the server container
  2. Open the Web Container Preview in the same browser
  3. Visit the site
  4. Return to the Server Container Preview

What you should see:

  • collect?v=2&tid=G-XXXXXXXX requests 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:

  1. CSP is the first thing to check when something isn't working
  2. The event_id format must be identical on browser and server — every character
  3. The Cookiebot GTM template with an empty consent table is the correct configuration
  4. sGTM Preview requires the right order — open the server container first
  5. --min-instances 0 is fine for a personal site; use --min-instances 1 for 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.

Let's Connect

Ready to discuss your project? Reach out through any of these channels.

Based in Bratislava, Slovakia. Available for projects worldwide.

Server-Side Tracking on Next.js: Meta CAPI, sGTM and Consent Mode v2 – A Complete Guide