Connect google analytics to Next.js App
Saturday 23/07/2022
·5 min readConnecting Google Analytics to a Next.js app is a 15-line change, but most tutorials skip the parts that actually matter — skipping GA in development, tracking client-side route changes (Next.js's biggest GA gotcha), and loading the tag without blocking your LCP. Below: a complete GA4 setup for the Next.js Pages router that handles all three, plus the App router variant and a consent-management add-on.
Pages router setup
In pages/_app.tsx, add two <Script> tags with strategy="lazyOnload":
import Script from 'next/script'
const GA_ID = process.env.NEXT_PUBLIC_GA_ID
export default function MyApp({ Component, pageProps }) {
return (
<>
{process.env.NODE_ENV === 'production' && GA_ID && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="lazyOnload"
/>
<Script id="gtag-init" strategy="lazyOnload">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}', { send_page_view: false });
`}
</Script>
</>
)}
<Component {...pageProps} />
</>
)
}
Two important things in that snippet:
process.env.NODE_ENV === 'production'— gates the loading so GA only runs on the live site, not inpnpm dev. Without this gate, your developer hours dirty your analytics.send_page_view: false— disables GA's automatic pageview tracking. We'll trigger pageviews manually on route change instead, because GA's auto-tracker doesn't see Next.js client-side navigation.
Tracking client-side route changes
Next.js does client-side navigation by default — clicking a <Link> doesn't reload the page. GA's auto-pageview tracker fires on hard page loads only, so without manual tracking you'd see one pageview per session no matter how many pages the user visited.
Listen to the router's routeChangeComplete event:
import { useEffect } from 'react'
import { useRouter } from 'next/router'
function usePageviewTracking() {
const router = useRouter()
useEffect(() => {
const handleRouteChange = (url: string) => {
if (typeof window.gtag === 'function') {
window.gtag('config', GA_ID, { page_path: url })
}
}
router.events.on('routeChangeComplete', handleRouteChange)
return () => router.events.off('routeChangeComplete', handleRouteChange)
}, [router.events])
}
// in _app.tsx
export default function MyApp({ Component, pageProps }) {
usePageviewTracking()
return (/* ... */)
}
Each navigation now fires gtag('config', ..., { page_path: url }), which GA4 treats as a pageview. Custom events fire the same way: window.gtag('event', 'sign_up', { method: 'email' }).
App router setup
App router doesn't expose router.events, so route-change tracking uses the usePathname and useSearchParams hooks instead:
'use client'
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
export function GoogleAnalytics({ id }: { id: string }) {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (typeof window.gtag === 'function') {
const url = pathname + (searchParams.toString() ? `?${searchParams}` : '')
window.gtag('config', id, { page_path: url })
}
}, [pathname, searchParams, id])
return null
}
Mount this component once in your root layout and add the <Script> tags from the Pages-router example in layout.tsx.
Environment variables
Use NEXT_PUBLIC_GA_ID (the NEXT_PUBLIC_ prefix makes it available to client code):
# .env.local
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
Without the NEXT_PUBLIC_ prefix, the variable is server-only — and since GA runs in the browser, your tag never gets the ID.
Type definitions
Add this once to a types/global.d.ts so window.gtag is properly typed:
export {}
declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'consent' | 'js',
target: string | Date,
params?: Record<string, unknown>
) => void
dataLayer: unknown[]
}
}
Consent management
Under GDPR/CCPA, you may need to delay GA until the user has consented. Use the consent command:
// before any other gtag call
window.gtag('consent', 'default', {
ad_storage: 'denied',
analytics_storage: 'denied',
})
// after user accepts:
window.gtag('consent', 'update', {
ad_storage: 'granted',
analytics_storage: 'granted',
})
GA queues events until consent is granted. If consent is denied, events are dropped. For full consent banners, use a library like react-cookie-consent or cookiebot.
Verification
- DevTools Network tab: filter by
collect— every pageview hitshttps://www.google-analytics.com/g/collect?.... - GA Realtime report: visit your live site, then check GA → Reports → Realtime. Your session should appear within 30 seconds.
- GA DebugView: enable debug mode by adding
?debug_mode=1to the URL or installing the GA Debugger Chrome extension. Events show up in real time in GA → Admin → DebugView.
Edge cases worth knowing
localhostdoesn't count by default — GA filters internal-traffic IPs based on filters in property settings. Add your IP via Admin → Data Streams → Configure tag settings → Define internal traffic.- Hash-only changes (
#section) aren't tracked by Next.js'srouteChangeComplete— you'd need a separatehashchangelistener if your site uses anchor-link navigation as the primary flow. - Server-side rendered analytics events don't work in Next.js — gtag is browser-only. For server-side events, use GA4's Measurement Protocol with a server-side API call instead.
- Strict CSP can block GA — if you have a Content-Security-Policy header, add
https://www.googletagmanager.comtoscript-srcandhttps://www.google-analytics.comtoconnect-src.
FAQ
Why do I need send_page_view: false?
GA4's default behavior fires a pageview on initial page load only. Next.js does client-side navigation, so without disabling auto-pageviews and triggering them manually on routeChangeComplete, you'd lose tracking for every navigation after the first.
Should I use Google Tag Manager instead of direct gtag?
GTM is better if non-engineers need to configure tracking (marketing teams). The setup is similar — load the GTM script once and configure tags in the GTM UI. For an engineer-managed site, gtag.js direct is one less moving part.
Does the <Script> strategy matter for GA?
lazyOnload is the right choice — it defers loading until the browser is idle, so GA doesn't compete with your LCP. afterInteractive is also acceptable but loads slightly earlier. Never use beforeInteractive for analytics; you'd be blocking your initial render for tracking.
How do I track custom events with GA4?
window.gtag('event', 'event_name', { param1: 'value' }). Event names should match GA4's recommended events when possible (sign_up, purchase, view_item) — those get special treatment in the UI. Custom event names also work but require manual configuration to show up in standard reports.