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

Server-Side Tracking na Next.js: Meta CAPI, sGTM a Consent Mode v2 - Kompletný sprievodca

Ako som postavil kompletný server-side tracking stack na Next.js 15 s Meta Conversions API, Google Tag Manager server kontajnerom, GA4 a Consent Mode v2. Všetko od nuly, vrátane chýb a ich riešení.

Milan PavlákMilan Pavlák
13 min čítania
Server-Side Tracking na Next.js: Meta CAPI, sGTM a Consent Mode v2 - Kompletný sprievodca

Toto nie je ďalší návod na inštaláciu Google Analytics. Toto je dokumentácia celého procesu, ktorým som prešiel pri budovaní produkčného tracking stacku na milanpavlak.sk, od prvého príkazu v Google Cloud Shell po prvý deduplikovaný Lead event v Meta Events Manager.

Ak chcete len výsledok bez kontextu, preskočte rovno na sekciu architektúry. Ak chcete pochopiť, prečo bolo každé rozhodnutie urobené, čítajte od začiatku.


Prečo Server-Side Tracking

Klasické client-side tracking skripty bežia v prehliadači návštevníka. To ich robí zraniteľnými voči:

  • Ad blockerom, ktoré blokujú Meta Pixel a Google Analytics pre 20-40 % používateľov
  • Safari ITP (Intelligent Tracking Prevention), ktorý obmedzuje životnosť cookies na 7 dní
  • iOS App Tracking Transparency: iOS 14+ výrazne znížil účinnosť client-side atribúcie

Server-side tracking rieši tieto problémy odosielaním dát priamo zo servera na analytické platformy. Prehliadač nevie, že sa to deje, a ad blockery nemajú čo blokovať.

Pre každého, kto prevádzkuje reklamy a optimalizuje kampane na základe konverzných dát, je server-side tracking v tomto bode prakticky nevyhnutnosť.


Architektúra stacku

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)

Jeden hit z prehliadača putuje cez sGTM a súčasne cez priamy Route Handler na serveri, každý s rovnakým event_id pre správnu deduplikáciu.


Stack

Vrstva Nástroj Cena
Cookie consent Cookiebot (Usercentrics) Platený
Správa tagov v prehliadači GTM Web Container Zadarmo
Správa tagov na serveri GTM Server Container Zadarmo
sGTM hosting Google Cloud Run Zadarmo (free tier)
GA4 Google Analytics 4 Zadarmo
Meta Pixel + CAPI Meta Events Manager Zadarmo
Framework Next.js 15 App Router Zadarmo
Hosting Vercel Zadarmo (free tier)

Celkové mesačné náklady: 0 € (pri návštevnosti osobného webu)


Čo je sGTM a prečo ho potrebujete

Štandardný GTM beží v prehliadači návštevníka. sGTM (server-side Google Tag Manager) beží na serveri, ktorý kontrolujete, v tomto prípade Google Cloud Run.

Výhody:

  • GA4 dáta prechádzajú cez váš server, nie priamo do Google, čo vám dáva first-party cookies a lepšiu odolnosť voči ad blockerom
  • Jeden server kontajner môže odosielať dáta do GA4, Meta CAPI, Google Ads, TikTok, všetko z jedného miesta
  • Plná kontrola nad tým, čo presne sa odosiela

--min-instances 0 znamená, že kontajner sa vypne, keď nikto nenavštevuje stránku, takže za nečinnosť neplatíte nič. Pre osobný web sa pohodlne zmestíte do free tier limitu (2 milióny požiadaviek mesačne bez poplatku).


Fáza 1: Google Cloud Platform

Vytvorenie projektu

  1. Choďte na console.cloud.google.com
  2. Vytvorte nový projekt. Ja som svoj pomenoval milan-sgtm
  3. Prepojte fakturačný účet (vyžaduje sa karta, ale pri nízkom trafficu vám nič neúčtujú)
  4. Nastavte fakturačný alert, aby vás na faktúre nič neprekvapilo
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

Fáza 2: GTM Server Container + Cloud Run

Vytvorenie Server Containera v GTM

  1. Choďte na tagmanager.google.com
  2. Vytvorte nový kontajner → Cieľová platforma: Server
  3. Vyberte Manually provision tagging server
  4. Uložte si svoj Container Config string. Čoskoro ho budete potrebovať

Deploy na Google Cloud Run

Otvorte Cloud Shell v GCP konzole a spustite:

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"

HTTP 400 pri otvorení URL v prehliadači je správna odpoveď. Server beží, len nevie, čo robiť s bežnou požiadavkou z prehliadača.

Vlastná subdoména

Pridajte CNAME záznam v DNS:

metrics.yourdomain.com → ghs.googlehosted.com

V Cloud Run → Domain mappings nakonfigurujte svoju subdoménu a počkajte na propagáciu (od 2 minút po 24 hodín).


Fáza 3: Konfigurácia GTM Server Containera

GA4 Client

V server kontajneri → Clients → New → Google Analytics: GA4 (Web)

Nastavenia:

  • Default GA4 paths: zaškrtnuté
  • 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 (dôležité pre GDPR)
  • Trigger: All Pages

Meta CAPI Tag

Importujte šablónu z Community Template Gallery: Facebook Conversion API by stape-io

Nastavenia:

  • Event Name Setup Method: Inherit from client
  • Action Source: Website
  • API Access Token: z Meta Events Manager
  • Facebook Pixel ID: vaše Pixel ID
  • Generate _fbp cookie if it does not exist: zaškrtnuté
  • Enable Event Enhancement: zaškrtnuté
  • Tag Execution Consent Settings: Send data in case marketing consent given

Fáza 4: GTM Web Container

Aktualizácia Google Tagu

Pridajte tieto parametre k vášmu existujúcemu Google Tagu:

transport_url        → https://metrics.yourdomain.com
server_container_url → https://metrics.yourdomain.com

Toto je kľúčový krok. Bez transport_url GA4 stále chodí priamo do Google namiesto smerovania cez váš sGTM.

Šablóna Cookiebot GTM s prázdnou tabuľkou Default Consent State je správna. Prázdna tabuľka znamená denied globálne pre všetkých. Nič nie je potrebné pridávať.

Čo by malo byť zaškrtnuté:

  • Enable Google Consent Mode: ✓
  • Enable URL passthrough: ✓
  • Advertiser Consent Mode: ✓

Trigger: Consent Initialization, All Pages (nie All Pages; v načasovaní je výrazný rozdiel)

Meta Pixel tagy a ich triggery

Kľúčový poznatok: Meta Pixel tagy nesmú mať trigger All Pages. Musia počkať na súhlas používateľa.

Správny trigger pre Meta Pixel tagy: cookie_consent_update (Cookiebot ho vytvára automaticky)

Každý Meta Pixel tag musí mať:

  • Consent Settings → Require additional consent: ad_storage
  • Tag sequencing → fires AFTER the Meta Pixel Base tag

Zoznam tagov

Web Container:

Tag Typ 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

Dôležité: Povoľte vstavané premenné: Scroll Depth Threshold, Scroll Depth Units, Scroll Direction, Page Path, Page URL. Bez nich dostanete chyby "Unknown variable".


Fáza 5: Next.js kód

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

Toto musí prísť pred GTM skriptom. Inak sa tagy spustia pred nastavením predvoleného stavu súhlasu:

<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);
    `,
  }}
/>

GTM skript musí používať strategy="afterInteractive", nie strategy="lazyOnload".

Kľúčový element pre deduplikačné event ID. Server-side Route Handler nastaví 32-znakový 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
}

Formát event_id

EventName_fbevtToken_pageLoadId
example: Lead_contact_db1456f37a2da2e95957e366050396ef_8qjw3p1ba

Toto je najdôležitejšia časť deduplikácie. Prehliadač a server musia odoslať identické event ID pre rovnakú akciu.

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 })
}

Komponent TrackingProvider

Client komponent, ktorý obaľuje aplikáciu. Inicializuje cookies, sleduje zmeny pathname a odosiela ViewContent a PageView CAPI eventy:

// 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}</>
}

Inštrumentácia formulárov

Pri každom formulári po úspešnom odoslaní:

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,
  })
}

Problémy, na ktoré som narazil

Content Security Policy blokovala všetko

Po prvom deployi Meta Pixel nefungoval. CSP blokovala connect.facebook.net aj sGTM subdoménu. Riešenie: pridať chýbajúce domény do CSP v 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 je prvá vec, ktorú treba skontrolovať, keď niečo nevysvetliteľne prestane fungovať po deployi.

Nesúlad event ID pri deduplikácii

Prehliadač: Lead_contact_618e1f..._y1yf4evml Server: Lead_618e1f..._y1yf4evml

GTM Meta Pixel Lead tag generoval event_id s prefixom Lead_contact_, zatiaľ čo CAPI Route Handler generoval iba Lead_. Riešenie: synchronizovať formát v Next.js kóde:

// 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

Race condition s window._fbPageId

PageView CAPI event generoval event_id skôr, ako Meta Pixel Base tag stihol nastaviť _fbPageId. Výsledok: nopid v event_id a nefunkčná deduplikácia. Riešenie: 100ms oneskorenie pred generovaním PageView event_id.

Vstavané premenné GTM neboli povolené

Chyba: Unknown variable "Scroll Depth Threshold" a Unknown variable "Page Title". Riešenie: GTM → Variables → Configure Built-In Variables → povoľte skupinu Scroll Depth a skupinu Pages.

Meta Pixel skript blokovaný CSP

Prvý test ukázal, že fbevents.js sa nedal načítať. Príčina: CSP neobsahovala connect.facebook.net v script-src. Pridaná aj do connect-src a frame-src.


Overenie

Meta Events Manager: Test Events

  1. Choďte do Events Manager → váš Pixel → Test Events
  2. Získajte Test Event Code (napr. TEST19036)
  3. Nastavte vo Vercel env vars: META_TEST_EVENT_CODE=TEST19036
  4. Redeploynite a navštívte stránku

Čo hľadať:

  • Browser events: PageView, ViewContent
  • Server events: ViewContent (z CAPI Route Handlera), PageView
  • Deduplicated badge na server eventoch. To je potvrdenie, že systém funguje

sGTM Server Container Preview

Na poradí záleží:

  1. Otvorte Server Container Preview ako prvý (kliknite Preview v server kontajneri)
  2. Otvorte Web Container Preview v tom istom prehliadači
  3. Navštívte stránku
  4. Vráťte sa do Server Container Preview

Čo by ste mali vidieť:

  • collect?v=2&tid=G-XXXXXXXX požiadavky prichádzajúce na sGTM
  • GA4 - Server Side: Fired X times
  • Meta CAPI - Server Side: Fired X times
  • Tags not fired: None ✓

Výsledky

Čo Stav
GA4 client-side
GA4 server-side cez sGTM
Meta Pixel browser
Meta CAPI server
Deduplikácia
Consent Mode v2 (Advanced)
Tracking formulárov s hashovaným PII
Scroll depth 25/50/75/90%
ViewContent na stránkach služieb
Súlad s GDPR

User data keys v Meta Events Manager po odoslaní formulára:

  • Country ✓
  • External ID (sha256 session tokenu) ✓
  • Browser ID (_fbp) ✓
  • IP address ✓
  • User agent ✓
  • Email (sha256) ✓
  • First name (sha256) ✓
  • Last name (sha256) ✓

Čo som vynechal (na neskôr)

Google Ads Enhanced Conversions odosiela hashovaný email/telefón do Google Ads pre lepšiu atribúciu. Vyžaduje aktívne Google Ads konverzie a je to samostatný GTM setup. Má zmysel riešiť to, keď skutočne beží Google Ads kampaň.


Zhrnutie

Celý stack beží na Vercel free tier + Google Cloud Run free tier. Pre osobný web sú mesačné náklady 0 €.

Čas implementácie: jeden dlhý deň od nuly po plne funkčný produkčný setup.

Kľúčové poznatky:

  1. CSP je prvá vec, ktorú treba skontrolovať, keď niečo nefunguje
  2. Formát event_id musí byť identický v prehliadači aj na serveri. Každý znak
  3. Šablóna Cookiebot GTM s prázdnou tabuľkou súhlasu je správna konfigurácia
  4. sGTM Preview vyžaduje správne poradie: otvorte server kontajner ako prvý
  5. --min-instances 0 je v poriadku pre osobný web; pre produkčných klientov s aktívnymi reklamnými kampaňami použite --min-instances 1

Ak hľadáte pomoc s implementáciou trackingu, pozrite si moje služby webovej analytiky. Pre WordPress alternatívu rovnakého konceptu odporúčam návod na Meta Pixel a Conversions API pre WordPress.

Ak pracujete na podobnom nastavení alebo máte otázky, neváhajte sa ozvať. Kontaktný formulár na tejto stránke funguje a sleduje konverzie. Viem to.

Spojme sa

Chcete sa porozprávať o vašom projekte? Ozvite sa mi cez ktorýkoľvek z týchto kanálov.

Sídlo v Bratislave, Slovensko. K dispozícii pre projekty po celom svete.

Try me — I'm alive
Server-Side Tracking na Next.js: Meta CAPI, sGTM a Consent Mode v2 - Kompletný sprievodca