GA4 Page Views Not Tracking in Next.js App Router
GA4 page views not firing on client-side navigation in Next.js App Router? Track route changes with usePathname, useSearchParams, and a manual gtag event.
Muhammad Qasim
Senior Full Stack Developer
The Problem
I ran into this on a client migration from Pages Router to App Router. The landing page tracked fine in GA4, but as soon as a user clicked into a product page or used the site nav, GA4 recorded nothing. Realtime showed the initial pageview and then flatlined. Session duration was tanked. Conversion events attached to pageviews were missing. The owner thought traffic had collapsed.
Traffic was fine. GA4 was just blind to every route after the first one.
Why It Happens
Pages Router exposed router.events so you could listen for routeChangeComplete and fire a manual pageview event. App Router dropped that API. It also switched navigation to a soft-transition model where window.location updates but no full page reload happens.
GA4's default config script sends a page_view event on initial gtag('config', 'G-XXXXX'). It does not fire again on SPA route changes. If you paste the standard Google Tag Manager or gtag snippet into your app/layout.tsx and move on, GA4 only ever sees the first route.
Three additional gotchas I see every month:
- Double-counting or zero counting with
send_page_view: false. Some blog posts tell you to setsend_page_view: falsein the config call and then forget to manually send the initial pageview. That kills the landing pageview too. - Stale
page_pathon query-only changes. On filter pages (/shop?category=shoes) the pathname does not change. If you only listen tousePathname, you miss navigations that only change the query string. useSearchParamsforces dynamic rendering. Anywhere you call it, Next.js bails out of static rendering for that route. If you add the tracker to your root layout without a Suspense boundary, your entire site drops out of static generation.
The Fix
Step 1: Inject gtag once in the root layout. Use next/script with afterInteractive so it does not block LCP:
// app/layout.tsx
import Script from 'next/script'
import { PageViewTracker } from '@/components/PageViewTracker'
import { Suspense } from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXX"
strategy="afterInteractive"
/>
<Script id="gtag-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXX', { send_page_view: false });
`}
</Script>
<Suspense fallback={null}>
<PageViewTracker measurementId="G-XXXXXXXX" />
</Suspense>
{children}
</body>
</html>
)
}
Two things to notice. send_page_view: false stops the automatic pageview, because we are going to send every pageview manually from the tracker, including the first one. The <Suspense> wrapper around PageViewTracker is required because the component uses useSearchParams.
Step 2: Build the PageViewTracker component. This runs only on the client:
// components/PageViewTracker.tsx
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
declare global {
interface Window {
gtag?: (...args: unknown[]) => void
}
}
export function PageViewTracker({ measurementId }: { measurementId: string }) {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (typeof window.gtag !== 'function') return
const query = searchParams.toString()
const path = query ? `${pathname}?${query}` : pathname
window.gtag('event', 'page_view', {
page_path: path,
page_location: window.location.href,
page_title: document.title,
send_to: measurementId,
})
}, [pathname, searchParams, measurementId])
return null
}
The effect runs on mount (firing the landing pageview) and on every subsequent pathname or search param change (firing every soft navigation).
Step 3: Verify in GA4 DebugView. Install the Google Analytics Debugger Chrome extension, or append ?debug_mode=1 to your URL once. Then watch realtime DebugView while you navigate. You should see a page_view event for every route change within two to five seconds. If you are not seeing them:
- Check the Network tab for requests to
google-analytics.com/g/collect. One should fire per navigation. - If none fire,
window.gtagis undefined. Yournext/scriptstrategy or CSP is blocking it. - If they fire but DebugView is empty, your property ID is wrong or you have a data filter excluding your IP.
Step 4: Handle the static-rendering tradeoff. useSearchParams forces dynamic rendering in the subtree that uses it. Because we wrapped the tracker in <Suspense>, the rest of the layout can still render statically while the tracker bails out. This keeps your ISR pages intact. If you skip the Suspense boundary, Next.js will log a warning and mark every route as dynamic.
The official Next.js docs on third-party integrations have the same pattern if you prefer to read it there.
One Edge Case Worth Knowing
If you use <ViewTransition> for page navigation (new in Next.js 16), the transition is queued before the effect re-runs. That can cause the page_title to lag one render behind on very fast clicks. If that matters for your analytics, switch from document.title to reading a title from route metadata via generateMetadata and passing it in as a prop, or send the page_view event from inside a useEffect with a requestAnimationFrame delay.
Wrap Up
App Router did not kill analytics. It just moved the responsibility for firing pageviews to you. Five lines of code and a Suspense boundary will get GA4 tracking every route transition correctly.
If you need someone to migrate a live marketing site without dropping rankings or breaking attribution, that is the bulk of my consulting work — see my services, or read how I approach a Next.js vs WordPress decision at the start of a project.
Need Help With This?
I offer professional web development services — WordPress, React/Next.js, performance optimization, and technical SEO.
Get in Touch