QasimCode.
ServicesPortfolioBlogContactHire Me
Home/Blog/GA4 Double-Counting Page Views in Next.js App Router
GA4Next.jsAnalytics

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.

MQ

Muhammad Qasim

Senior Full Stack Developer

April 17, 2026
5 min read

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:

  1. The App Router does a client-side transition
  2. Your custom useEffect listener sees the pathname change and sends a manual page_view
  3. 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
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 Page Views Not Tracking 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