QasimCode.
ServicesPortfolioBlogContactHire Me
Home/Blog/GA4 Page Views Not Tracking in Next.js App Router
SEOAnalyticsNext.js

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.

MQ

Muhammad Qasim

Senior Full Stack Developer

April 17, 2026
5 min read

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:

  1. Double-counting or zero counting with send_page_view: false. Some blog posts tell you to set send_page_view: false in the config call and then forget to manually send the initial pageview. That kills the landing pageview too.
  2. Stale page_path on query-only changes. On filter pages (/shop?category=shoes) the pathname does not change. If you only listen to usePathname, you miss navigations that only change the query string.
  3. useSearchParams forces 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.gtag is undefined. Your next/script strategy 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
All Posts

About the Author

MQ

Muhammad Qasim

Senior Full Stack Developer with 5+ years experience in React, Next.js, and WordPress. Based in Pakistan, working globally.

Need a Web Developer?

I build WordPress sites, React apps, and optimize web performance.

View Services

Related Posts

  • GA4 Double-Counting Page Views in Next.js App Router5 min read
  • Next.js 16 revalidateTag Not Working: Production Fix5 min read
  • Next.js use cache Returning Stale Data After Deploy5 min read

QasimCode.

Senior Full Stack Developer building web solutions that deliver measurable growth.

hello@qasimcode.com

Services

  • WordPress Dev
  • React / Next.js
  • Performance
  • E-commerce
  • Technical SEO

Resources

  • Blog
  • Portfolio

Company

  • About
  • Contact
  • LinkedIn

© 2026 Muhammad Qasim. All rights reserved.

Pakistan — Remote worldwide