What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Tutorials
Implement proper analytics tracking in your Next.js site when your pages change in the client-side router, without a full page refresh.
Adding Google Analytics to an App Router site is now mostly a matter of dropping one component into your root layout. The tricky part — tracking page views when the router navigates without a full page reload — is no longer something you write yourself, but it is something you have to verify.
Coming from the Pages Router? The old recipe here used the
react-gapackage plusrouter.events.on("routeChangeComplete", …)fromnext/router. Neither works now:next/routerdoes not exist in the App Router (there are no router events to subscribe to), andreact-gatargets Universal Analytics, which Google shut down in July 2023 — aUA-property no longer collects anything. Replace both with@next/third-partiesand a GA4G-measurement ID.
@next/third-partiesKeep its major version in step with your Next.js version:
npm install @next/third-parties
// app/[locale]/layout.tsx
import { GoogleAnalytics } from "@next/third-parties/google"
export default async function LocaleLayout({ children }) {
return (
<>
{children}
<GoogleAnalytics gaId="G-XXXXXXXXXX" />
</>
)
}
That is the whole integration. The component loads gtag.js through next/script with a sensible loading strategy, so it doesn't block your first paint.
A hard-coded ID means a deploy every time marketing changes properties. Add a googleAnalyticsID field to a global Settings content item and read it in the layout:
import { GoogleAnalytics } from "@next/third-parties/google"
import { getSettings } from "@/lib/cms-content/getSettings"
import { getAgilityContext } from "@/lib/cms/getAgilityContext"
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
const { isPreview } = await getAgilityContext(locale)
const settings = await getSettings({ locale, preview: isPreview })
const gaId = settings?.googleAnalyticsID || null
return (
<>
{children}
{gaId && <GoogleAnalytics gaId={gaId} />}
</>
)
}
Because that read is cached and tagged like any other Agility content, publishing a new ID invalidates the layout and the change goes live without a build. Rendering nothing when the field is empty also keeps analytics off your preview and local environments for free.
This is the part the old guide existed to solve, and the answer has moved.
<GoogleAnalytics> runs gtag('config', …) once, when it mounts. It does not subscribe to route changes — so if you read the source expecting to find navigation tracking, you won't.
It still works, because of something on the Google side: GA4's Enhanced measurement includes "Page changes based on browser history events", and App Router navigation is history.pushState. GA4 sees the URL change and records a page_view by itself.
⚠️ Verify this rather than assuming it. In GA4, open Admin → Data streams → your web stream → Enhanced measurement, expand the settings, and confirm "Page changes based on browser history events" is on. It is on by default, but it is also the single switch that decides whether every navigation after the first one is counted. When someone reports "only the landing page is tracked," this is almost always why.
You only need this if you've turned enhanced measurement off, or you want extra parameters on the event. It's a small client component:
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { sendGAEvent } from "@next/third-parties/google"
export function PageViewTracker() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
const query = searchParams.toString()
sendGAEvent("event", "page_view", {
page_path: query ? `${pathname}?${query}` : pathname,
})
}, [pathname, searchParams])
return null
}
⚠️
useSearchParams()must sit inside a<Suspense>boundary. It is request-time data, so without one it opts the whole route out of static rendering — and under Cache Components it fails the prerender outright.
<Suspense fallback={null}>
<PageViewTracker />
</Suspense>
Also make sure you aren't now counting each navigation twice: if you send these manually, turn the enhanced-measurement history setting off.
sendGAEvent works from any client component:
"use client"
import { sendGAEvent } from "@next/third-parties/google"
export function SignupButton() {
return (
<button onClick={() => sendGAEvent("event", "cta_clicked", { cta_name: "hero-signup" })}>
Get started
</button>
)
}
It pushes onto the same dataLayer the component created. Two consequences worth knowing:
<GoogleAnalytics> has mounted logs GA has not been initialized and drops the event. Fire events from user interactions, not from module scope.Same package, same shape — use this instead of GoogleAnalytics if GTM owns your tags:
import { GoogleTagManager } from "@next/third-parties/google"
<GoogleTagManager gtmId="GTM-XXXXXXX" />
"use client"
import { sendGTMEvent } from "@next/third-parties/google"
sendGTMEvent({ event: "cta_clicked", cta_name: "hero-signup" })
Don't load both for the same property. If GTM is already firing a GA4 configuration tag, adding <GoogleAnalytics> as well gives you duplicated page views.
If you need Google Consent Mode, the default consent state has to be set before gtag.js loads:
import Script from "next/script"
<Script id="consent-default" strategy="beforeInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('consent', 'default', { ad_storage: 'denied', analytics_storage: 'denied' });`}
</Script>
<GoogleAnalytics gaId={gaId} />
Your consent banner then calls gtag('consent', 'update', …) once the visitor chooses.
googletagmanager.com/gtag/js?id=G-… in the Network tab.<Link> — not a refresh — and confirm a second page view appears. This is the step that catches a misconfigured enhanced-measurement setting, and it's the one people skip.@next/third-parties → <GoogleAnalytics gaId="G-…" /> in the root layout. That's the integration.<Link> navigation.<Suspense>, with enhanced measurement turned off.sendGAEvent / sendGTMEvent for custom events, client-side only; use the Measurement Protocol on the server.