GA4 Scroll Events Not Firing in Next.js App Router

GA4 enhanced measurement scroll events stop firing after the first route in Next.js App Router. Here is why the listener detaches and the exact fix.
GA4 Scroll Events Not Firing in Next.js App Router
GA4Next.jsAnalytics
June 9, 20266 min read1081 words

The Problem

A client moved their content site from Pages Router to App Router on Next.js 16 last month. The week after deploy, their content team flagged that the GA4 dashboard had gone quiet on scroll engagement. The "Pages and screens" report still showed traffic. The "Events" report still showed page_view firing on every navigation. The scroll event — the 90%-of-page-depth signal that GA4 ships as part of Enhanced Measurement — was firing on the landing page only. Every subsequent client-side route showed zero scroll events, even on long articles users were clearly reading to the end (judging by ad revenue from below-the-fold ads).

Same gtag config, same GA4 property, same Enhanced Measurement settings. The only thing that changed was the router. Confirmed by hand in DebugView: scroll on /, see the event. Click any internal link, scroll on /blog/anything, no event. Hard reload /blog/anything, scroll, the event fires again. So the listener exists on first load, then dies on the first client-side navigation.

Why It Happens

GA4's Enhanced Measurement scroll tracking is a single listener that the gtag.js library attaches once to document when it initialises. It fires when the user has scrolled past 90% of the document's scrollHeight. The internal state — "has the threshold been crossed on this page?" — is tracked in a closure tied to the URL at attach time. When the URL changes, gtag.js is expected to reset the state and rebind.

In a classic full-page-load site, gtag.js re-initialises on every navigation because the script tag reruns. In a Pages Router site, the GA4 install uses next/script with router.events.on('routeChangeComplete') to send a manual page_view and the Enhanced Measurement listener catches each new page through the History API events gtag wires up.

App Router removed router.events. Navigation happens through a different mechanism — the streaming response replaces the segment tree client-side, but it does not always dispatch a fresh popstate event the way Pages Router did, and it never re-runs the gtag init script. The result is that gtag thinks the URL is still the original one. Its internal scroll-state for that "page" already fired once, so it ignores subsequent crossings of the 90% threshold. The listener is attached. It is just suppressing the event because, as far as gtag knows, you have not changed pages.

The GA4 measurement reference explicitly calls out that single-page apps need to manually re-fire page_view on each navigation. It does not say so directly, but you also need to nudge gtag's internal scroll-depth state, otherwise the same listener keeps suppressing.

The Fix

Send a manual page_view on every App Router navigation, AND reset the scroll-depth state so Enhanced Measurement re-arms. Both steps are required. Doing only the first leaves the scroll event broken.

Step 1: Hook into App Router navigation. App Router exposes usePathname and useSearchParams, which update on every client-side route change. Use them to detect navigation:

// app/_components/GAListener.tsx
'use client'

import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'

declare global {
  interface Window {
    gtag: (...args: unknown[]) => void
  }
}

const GA_ID = process.env.NEXT_PUBLIC_GA_ID!

export function GAListener() {
  const pathname = usePathname()
  const searchParams = useSearchParams()

  useEffect(() => {
    if (!window.gtag) return

    const url = pathname + (searchParams.toString() ? `?${searchParams}` : '')

    // Re-fire page_view so GA4 records the navigation.
    window.gtag('event', 'page_view', {
      page_path: url,
      page_location: window.location.href,
      page_title: document.title,
      send_to: GA_ID,
    })

    // Reset Enhanced Measurement scroll state so the 90% trigger re-arms.
    window.gtag('set', 'scroll_depth_threshold_reached', false)
  }, [pathname, searchParams])

  return null
}

The gtag('set', 'scroll_depth_threshold_reached', false) call is the line nobody documents. It clears the internal flag Enhanced Measurement uses to suppress duplicate scroll events on the same page. Without it, only the first route after page load gets a scroll event; with it, every route does.

Step 2: Wrap the GA install so it does not auto-fire page_view. If you leave Enhanced Measurement's auto page-view on, you end up double-counting. Disable it in the config and let your manual handler do it:

// app/layout.tsx
import Script from 'next/script'
import { Suspense } from 'react'
import { GAListener } from './_components/GAListener'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <Script
          src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`}
          strategy="afterInteractive"
        />
        <Script id="ga-init" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            window.gtag = gtag;
            gtag('js', new Date());
            gtag('config', '${process.env.NEXT_PUBLIC_GA_ID}', {
              send_page_view: false
            });
          `}
        </Script>
        <Suspense>
          <GAListener />
        </Suspense>
        {children}
      </body>
    </html>
  )
}

The send_page_view: false flag stops Enhanced Measurement from firing its own page_view on init. Your GAListener fires one on every pathname change instead, including the initial load (because useEffect runs after first mount). The Suspense wrapper is required because useSearchParams() opts the tree into client-side rendering — without it, the build fails with a static generation error.

Step 3: Verify in DebugView. Open https://analytics.google.com/analytics/web/#/debugview/, then in another tab:

  1. Load any article on your site.
  2. Scroll to the bottom. The scroll event should appear in DebugView within a few seconds.
  3. Click an internal link to a different article.
  4. Scroll to the bottom of that one. The scroll event should appear again.

If you see exactly one scroll event after both pages, the reset call is not landing — confirm GAListener mounted by setting a console.log inside the effect.

The Lesson

App Router removed the navigation events that gtag.js relied on, and Enhanced Measurement suppresses repeat scroll events on what it thinks is the same page. The fix is two lines: send a manual page_view on usePathname change, and set('scroll_depth_threshold_reached', false) so the 90%-depth listener re-arms. Without the second call you only ever get one scroll event per real page load, no matter how many client-side routes the user reads.

If your GA4 events went dark after migrating to App Router, that is the work I do. See my services. For another GA4-after-migration trap, read GA4 page views in Next.js App Router.

Analytics went dark after a Next.js migration? Get it shipped.

Back to blogStart a project