GA4 Double-Counting Page Views in Next.js App Router
GA4 is firing two page_view events per navigation in the Next.js App Router. Here is the root cause and the exact fix with a working listener.
Muhammad Qasim
Senior Full Stack Developer
Problem
A client DM'd me their GA4 dashboard last Friday. Sessions up 40% month over month. Pageviews up 90%. Engagement rate down. No new traffic source, no campaign — the numbers were wrong. On the DebugView tab, every navigation was firing page_view twice in the App Router.
If you installed GA4 on a Next.js App Router site using the <Script> tag with a standard config snippet, you're probably affected right now. Check the Realtime → DebugView in GA4 and click around your site. If each click shows two page_view rows within milliseconds, this post is for you.
Why It Happens
GA4's default gtag('config', ...) call sends a page_view automatically on load. That's fine for a traditional server-rendered site where each navigation triggers a full page reload and gtag.js reinitializes. In the App Router, though, navigations are client-side. The gtag.js library is already loaded. And it has send_page_view: true by default.
So what happens on every route change is:
- The App Router does a client-side transition
- Your custom
useEffectlistener sees the pathname change and sends a manualpage_view - GA4's internal history listener also detects the URL change and sends its own automatic
page_view
Two events, one navigation. Traffic volume looks great, every other metric gets distorted.
A lot of tutorials on the web still show this broken pattern:
// ❌ Double-counts in App Router
"use client";
import Script from "next/script";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
export function Analytics() {
const pathname = usePathname();
useEffect(() => {
window.gtag?.("event", "page_view", { page_path: pathname });
}, [pathname]);
return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=G-XXXX`} />
<Script id="ga4">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXX');`}
</Script>
</>
);
}
The gtag('config', 'G-XXXX') call has an implicit send_page_view: true. So on initial load and every history change, GA4 sends a page_view on its own. The useEffect sends a second one. Boom — 2x everything.
The Fix
You have two clean options. Pick one, don't mix them.
Option A: Let GA4 handle it (simpler, usually correct).
GA4's enhanced measurement already tracks SPA navigations via the History API. Just turn it on and remove your manual listener:
"use client";
import Script from "next/script";
export function Analytics() {
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${process.env.NEXT_PUBLIC_GA_ID}`}
strategy="afterInteractive"
/>
<Script id="ga4" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${process.env.NEXT_PUBLIC_GA_ID}');`}
</Script>
</>
);
}
Then in GA4 → Admin → Data streams → Web → Enhanced measurement → gear icon → enable Page changes based on browser history events. This is on by default in new properties created after early 2024, but older properties often have it off.
Option B: Take full control (for custom page titles, virtual pageviews).
Disable GA4's automatic page_view and fire it yourself on pathname change:
"use client";
import Script from "next/script";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, Suspense } from "react";
const GA_ID = process.env.NEXT_PUBLIC_GA_ID!;
function PageViewTracker() {
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,
page_title: document.title,
page_location: window.location.href,
});
}, [pathname, searchParams]);
return null;
}
export function Analytics() {
return (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="ga4" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${GA_ID}', { send_page_view: false });`}
</Script>
<Suspense fallback={null}>
<PageViewTracker />
</Suspense>
</>
);
}
The key line is send_page_view: false. That disables the automatic pageview on init, and the useEffect now owns the tracking. Wrap the tracker in <Suspense> because useSearchParams forces the closest boundary into client rendering.
Gotchas I Hit
Enhanced measurement can still double-fire with Option B. If you flip send_page_view: false but leave "Page changes based on browser history events" enabled in GA4, you'll get back to double-counting. Pick one source of truth.
GTM adds another layer. If you're loading GA4 through Google Tag Manager, disable the built-in GA4 tag's automatic pageview trigger and manage it explicitly. Having gtag.js loaded twice (once directly, once via GTM) is the other common cause of 2x traffic.
Type window.gtag. I keep a global types file:
// types/global.d.ts
declare global {
interface Window {
dataLayer: unknown[];
gtag: (...args: unknown[]) => void;
}
}
export {};
Saves me from // @ts-ignore every time.
Verify before you move on. Run your site through the GA4 DebugView with the GA Debugger Chrome extension on. You want exactly one page_view per navigation, with the right page_path and page_title.
For the rest of the Core Web Vitals picture — LCP, INP, CLS — my Core Web Vitals guide walks through the metrics that actually move rankings.
Analytics Numbers Look Off?
I audit analytics setups on Next.js and WordPress sites and untangle double-counting, missed conversions, and GTM spaghetti. If your GA4 numbers don't match reality, book a tracking audit on my services page.
Need Help With This?
I offer professional web development services — WordPress, React/Next.js, performance optimization, and technical SEO.
Get in Touch