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
- Choďte na console.cloud.google.com
- Vytvorte nový projekt. Ja som svoj pomenoval
milan-sgtm - Prepojte fakturačný účet (vyžaduje sa karta, ale pri nízkom trafficu vám nič neúčtujú)
- 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
- Choďte na tagmanager.google.com
- Vytvorte nový kontajner → Cieľová platforma: Server
- Vyberte Manually provision tagging server
- 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.
Consent Mode v2: Čo som sa naučil o Cookiebot
Š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
Consent Defaults v layout.tsx
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".
_fbevt Session Cookie
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
- Choďte do Events Manager → váš Pixel → Test Events
- Získajte Test Event Code (napr.
TEST19036) - Nastavte vo Vercel env vars:
META_TEST_EVENT_CODE=TEST19036 - 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ží:
- Otvorte Server Container Preview ako prvý (kliknite Preview v server kontajneri)
- Otvorte Web Container Preview v tom istom prehliadači
- Navštívte stránku
- Vráťte sa do Server Container Preview
Čo by ste mali vidieť:
collect?v=2&tid=G-XXXXXXXXpož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:
- CSP je prvá vec, ktorú treba skontrolovať, keď niečo nefunguje
- Formát event_id musí byť identický v prehliadači aj na serveri. Každý znak
- Šablóna Cookiebot GTM s prázdnou tabuľkou súhlasu je správna konfigurácia
- sGTM Preview vyžaduje správne poradie: otvorte server kontajner ako prvý
--min-instances 0je 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.