
Build Note
GA4 without gtag: a first-party collector
How tallkarol.com sends events to GA4 with no gtag.js and no GTM — a small first-party script, one API route, and the Measurement Protocol. CSP stays 'self'.
Published August 4, 2026
tallkarol.com sends events to GA4 with no gtag.js, no GTM, and no third-party script of any kind: a ~250-line vanilla collector posts to one same-origin API route, which validates events and forwards them to GA4's Measurement Protocol. The Content-Security-Policy stays locked to 'self', and the browser never talks to Google.
Why not just add gtag.js?
Because this site's CSP forbids third-party requests outright — script-src 'self', connect-src 'self' — and gtag.js would need both loosened, plus a permanent seat on the performance budget. Rather than weaken the policy to fit the tool, the tool was rebuilt to fit the policy. That's server-side tagging in its smallest possible form: no tagging server, just a route.
How does the collector work end to end?
Three pieces, all first-party. A tiny inline snippet in the layout runs before anything else, captures first-touch UTM and click-id parameters before client-side routing can rewrite the URL, and queues early events on window.tk.q. A deferred same-origin script (/tk-collect.js, plain JavaScript, no dependencies) boots, drains the queue, and starts observing. And one API route, /api/collect, receives batches and forwards the survivors to Google's Measurement Protocol:
POST https://www.google-analytics.com/mp/collect
?measurement_id=G-XXXXXXXXXX&api_secret=•••
{
"client_id": "1712345678.1724800000",
"timestamp_micros": 1724800000000000,
"non_personalized_ads": true,
"events": [
{ "name": "page_view",
"params": { "page_location": "https://…", "session_id": "…" } }
]
}Delivery uses navigator.sendBeacon() with a fetch(…, { keepalive: true }) fallback, so events fired during navigation still leave the page. The script tracks what gtag would: page_view on real navigations (by patching pushState/replaceState and listening to popstate), scroll depth at 25/50/75/90 percent, CTA and outbound clicks, and user_engagement on visibilitychange and pagehide.
How do you keep sessions and attribution without cookies?
GA4 doesn't require cookies — it requires a stable client_id. The collector keeps all state in web storage:
| Key | Store | Purpose |
|---|---|---|
| tk_cid | localStorage | GA4-style client id: random number + first-seen timestamp |
| tk_first | localStorage | First-touch attribution (UTMs, click ids), 90-day TTL |
| tk_last | sessionStorage | Last-touch attribution for the visit |
| tk_sid / tk_slat | sessionStorage | Session id + last activity; a new session after 30 idle minutes |
Attribution capture covers utm_* plus the paid click ids: gclid, gbraid, wbraid, msclkid, fbclid, li_fat_id. First-touch is written once and defended for 90 days; last-touch updates per session. That distinction is what lets a lead be traced to the campaign that first introduced the visitor, not just the one that closed them.
What does the server refuse to forward?
The route is the checkpoint that a client-side setup never gets. Everything is deny-by-default:
const EVENT_ALLOW = new Set([
"page_view", "scroll", "cta_click", "outbound_click",
"user_engagement", "session_start", /* … */
])
const PII_PATTERN = /email|e-mail|name|phone|tel|company|address|user_id/i
// generate_lead is deliberately NOT in EVENT_ALLOW.
// Only the server-side form handler may emit it.- An event allowlist (10 names) and a parameter allowlist (~25 keys). Unknown events and params are silently dropped, so a typo can't pollute the property and a malicious client can't inject junk dimensions.
- A PII pattern check — any parameter whose name looks like an email, name, phone, or address field is stripped before the payload leaves the origin.
- Rate limiting: 60 requests per minute per IP, at most 10 events per request.
generate_leadis rejected from the public route entirely. Only the server-side form handler that actually received an inquiry may emit it — so the one number that gets treated as money can't be spoofed by anything that runs in a browser.- Every forwarded payload sets
non_personalized_ads: trueand stampstimestamp_microsserver-side.
How do you debug Measurement Protocol events?
Blind is the default failure mode here: the Measurement Protocol returns 204 whether or not GA4 understood the payload. Three steps make it observable:
- In development, mirror every payload to
/debug/mp/collect— Google's validation endpoint — and log thevalidationMessagesit returns. This collector does that automatically outside production and behind a?debug=1flag. - Watch GA4's DebugView while sending test events to confirm params arrive under the names you expect.
- Keep a "send test hit" button in your admin tooling that fires one real
page_viewthrough the full path — the fastest possible answer to "is the pipeline up?".
What did it cost, and what would change at scale?
The collector is ~250 lines of dependency-free JavaScript served from the site's own origin; the route is one file. Nothing was added to the client bundle's framework path, the CSP kept default-src 'self', and the site's Lighthouse profile stayed exactly where it was. The honest limitation: this is a one-destination design. The day a second vendor needs the event stream is the day the route grows into a real tagging server — and that trade-off is covered in the server-side tagging overview. Reading the data back out again is its own topic: pulling GA4 reports with the Data API.
Questions
Is the GA4 Measurement Protocol free to use?
Yes. It posts events into a standard GA4 property, which is free at this scale. The only infrastructure is the forwarding route, which runs inside the site's existing hosting.
Does GA4 work without cookies?
Yes — GA4 needs a stable client_id string, and it does not care where that string lives. This collector keeps it in localStorage and never sets a cookie, which also means no cookie is sent to the server on every request for free.
Why don't Measurement Protocol events show up like normal traffic?
GA4 attributes sessions from parameters gtag.js normally supplies — session_id and engagement time in particular. A collector has to construct and send those itself, or events land in the property without joining sessions. Validate against the debug endpoint before trusting any report.
Can this replace Google Tag Manager for ads platforms too?
Not directly — this pattern forwards to exactly one destination. If Meta, LinkedIn, and Google Ads all need conversions, that fan-out is what a tagging server such as server-side GTM is for.
Related notes
- What is server-side tagging?
- Keeping 100 Lighthouse scores behind a strict CSP
- How to pull GA4 reports with the Data API
More on web development
- Claude Code rules for Elementor sites
- Claude Code rules for WCAG testing
- Cursor rules for WCAG testing
- Animating hero text without layout shift
Related service: Web Development
Want this kind of engineering on your project?
Tall Karol takes on fractional and project-based engagements for startups and agencies.
Book a working session