The Problem
A client's GA4 property was reporting scroll engagement on the homepage and nowhere else. Average engagement time on inner pages looked artificially low. The marketing team flagged it during a quarterly review, asking why blog posts with 1,500-word articles were showing zero 90% scroll events while the homepage with a single hero section was reporting them fine.
I checked the GA4 debug view in real time. On the first page load after a hard refresh, the scroll event fired at the 90% threshold as expected. Click any internal link, route to a blog post, scroll all the way down, nothing. The Network tab confirmed it: /g/collect requests for page_view were going out on every navigation, but scroll events never followed.
The site was using the GA4 enhanced measurement feature, configured through the Google tag UI, with the Next.js Script component loading gtag.js:
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX" strategy="afterInteractive" />
<Script id="gtag-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || []
function gtag(){dataLayer.push(arguments)}
gtag('js', new Date())
gtag('config', 'G-XXXXXX')
`}
</Script>
{children}
</body>
</html>
)
}
Page views were being tracked. Scroll events were not. The same setup on a plain HTML page tracked scroll perfectly. Something specific to Next.js client navigation was breaking the scroll listener.
Why It Happens
GA4 enhanced measurement registers its scroll listener once, on the initial DOMContentLoaded, against the document element of the original page. It tracks the deepest scroll position reached and fires the scroll event the first time that position crosses 90% of the document height. After it fires once for a given page, it does not re-arm.
That model assumes a full page load happens on every navigation. In a traditional MPA, the navigation tears down the document and a new DOMContentLoaded triggers a fresh listener. In the Next.js App Router, client-side navigation never fires a new DOMContentLoaded. The same listener stays bound, but the gtag('event', 'scroll') call has already been marked as sent for the "page" and gets suppressed. Worse, the deepest scroll position is not reset when the URL changes, so navigating from a long homepage to a short blog post can make GA4 think the deepest scroll is still wherever you left it on the homepage.
The same trap hits click, file_download, and video_engagement enhanced events. They all hang their state on the initial page lifecycle. The Next.js Script component documentation does not warn about this, because the Script component is doing its job correctly. It is the GA4 enhanced measurement that does not understand a soft navigation.
In Search Console behaviour terms, the same misunderstanding shows up as inflated bounce rate and undercounted engagement, both of which Google now uses as quality signals. Letting it run uncorrected for a quarter is a real revenue problem on content sites.
The Fix
Turn off the broken auto-scroll measurement and re-send page_view on every client navigation with a manual scroll signal. Two changes, both in the same client component.
Change 1: Disable the broken enhanced events and configure manual page views. In the GA4 admin UI, open the data stream, click Configure tag settings, and turn off the scroll, outbound clicks, and form interaction toggles. In the same screen, set the send_page_view configuration to false so we control page views from the client:
'use client'
import Script from 'next/script'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
const GA_ID = 'G-XXXXXX'
export function Analytics() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (!window.gtag) return
const url = pathname + (searchParams.toString() ? `?${searchParams}` : '')
window.gtag('event', 'page_view', { page_path: url })
}, [pathname, searchParams])
return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`} strategy="afterInteractive" />
<Script id="gtag-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || []
function gtag(){dataLayer.push(arguments)}
gtag('js', new Date())
gtag('config', '${GA_ID}', { send_page_view: false })
`}
</Script>
</>
)
}
Pathname-driven page views are the well-known half of the fix. The half that gets missed is the next step.
Change 2: Re-arm a custom scroll listener that resets on every navigation. Replace the enhanced scroll measurement with one that respects the App Router lifecycle:
'use client'
import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
export function ScrollTracking() {
const pathname = usePathname()
useEffect(() => {
let fired = false
const onScroll = () => {
if (fired) return
const scrolled = window.scrollY + window.innerHeight
const height = document.documentElement.scrollHeight
if (scrolled / height >= 0.9) {
fired = true
window.gtag?.('event', 'scroll', {
percent_scrolled: 90,
page_path: pathname,
})
window.removeEventListener('scroll', onScroll)
}
}
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [pathname])
return null
}
The effect re-binds on every pathname change. The fired flag is scoped to the closure, so a new navigation resets it. The cleanup removes the old listener before the next one binds, so there is no duplicate fire on rapid back-forward navigation. Mount Analytics and ScrollTracking once in the root layout, inside a Suspense boundary because useSearchParams requires one.
Verify with the GA4 DebugView and the realtime collect endpoint. Install the GA Debugger extension, open DebugView in the GA4 admin, then navigate the site:
1. Hard refresh the homepage. Confirm page_view event.
2. Click a blog post link. Confirm a second page_view with the new page_path.
3. Scroll to the bottom. Confirm a scroll event with percent_scrolled: 90.
4. Click back. Confirm a third page_view, scroll, and confirm the scroll fires again.
If the scroll event does not fire after the back navigation, the listener is not re-binding. Check that the effect dependency array includes pathname and that the cleanup is returning the correct function.
The Lesson
GA4 enhanced measurement assumes one DOMContentLoaded per page view. The Next.js App Router does not give it that. The scroll listener registers once, fires once, and then sits dead on every subsequent client navigation. Disable the auto-scroll toggle in the data stream UI, dispatch your own page views on pathname change, and bind a fresh scroll listener on every navigation. The DebugView confirms it in minutes.
If your Next.js analytics setup is undercounting and the marketing dashboards are getting blamed for it, that is a project I get paid to fix. See my services. For another GA4 trap from the same App Router stack, read GA4 double counting page views in App Router.
Need the analytics actually working on your Next.js site? Get it shipped.