Next.js gives you @next/third-parties/google to drop Google Analytics 4 in with one line. But the moment you go past a basic pageview — firing custom events like sign_up or post_publish — the docs go quiet and you hit a few silent traps. Here's a production-ready setup that actually fires GA4 custom events reliably.
1. Add GoogleAnalytics (the easy part)
1 2 3 4// app/layout.tsx (App Router) or pages/_app.tsx (Pages Router) import { GoogleAnalytics } from '@next/third-parties/google'; <GoogleAnalytics gaId="G-XXXXXXXXXX" />
This auto-sends page_view. The hard part is everything after.
2. Fire a custom event with sendGAEvent
1 2 3import { sendGAEvent } from '@next/third-parties/google'; sendGAEvent('event', 'sign_up', { method: 'github' });
Call it where the action succeeds — a React Query onSuccess, an OAuth callback, a submit handler.
3. Gotcha #1 — events silently vanish before gtag.js loads
sendGAEvent pushes to window.dataLayer. If it runs before the GA script created that array, the event is dropped with only a console warning. Pre-create the queue so early events survive:
1 2window.dataLayer = window.dataLayer || []; sendGAEvent('event', name, params);
gtag.js replays the existing dataLayer on load, so a pre-seeded queue is safe.
4. Gotcha #2 — dev & preview traffic pollutes your prod property
By default, localhost and Vercel preview deploys send events into your real GA4 property. Gate it:
1 2 3export const GA_ENABLED = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_VERCEL_ENV !== 'preview';
(Vercel exposes NEXT_PUBLIC_VERCEL_ENV only if Automatically expose System Environment Variables is on.)
5. A reusable, type-safe helper
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15import { sendGAEvent } from '@next/third-parties/google'; export type GAEventName = 'sign_up' | 'login' | 'post_publish' | 'post_view'; export function trackEvent( name: GAEventName, params: Record<string, string | number | boolean | null | undefined> = {}, ) { if (typeof window === 'undefined' || !GA_ENABLED) return; window.dataLayer = window.dataLayer || []; // gotcha #1 const clean = Object.fromEntries( // drop null/undefined Object.entries(params).filter(([, v]) => v != null), ); sendGAEvent('event', name, clean); }
Now every call is typo-proof: trackEvent('post_publish', { public_status: true, tag_count: 4 }).
6. Don't forget — register custom dimensions in GA4
A custom parameter like method or public_status won't show up in reports until you register it as a custom dimension (GA4 Admin → Custom definitions). It's not retroactive — add it before you collect data.
Gotcha checklist
- Pre-seed
window.dataLayer→ events aren't dropped. - Gate to production → no dev/preview noise.
- Register custom dimensions → params actually appear.
- Fire on success callbacks, not on click → you don't count failures.
Closing
One <GoogleAnalytics> tag, a typed trackEvent wrapper, three gotchas handled. Open GA4 Realtime and your custom events should appear within seconds.
