Avoiding Duplicate Page Views in GA4, Google Ads, and Floodlight on a Next.js SPA

When I first noticed the numbers creeping up, it didn’t look like real growth. GA4 sessions were suddenly livelier than they should’ve been, and downstream tools like Floodlight and Google Ads were echoing the same enthusiasm. Whenever I see that kind of alignment across systems, it usually means one thing: something somewhere is firing more than it should. So I dug in.

On SPAs like Next.js, this kind of situation isn’t rare. Because the page never truly reloads, multiple systems try to figure out when navigation happens, and they often overlap. GA4 has opinions. Next.js has opinions. Your custom code probably has opinions too. The moment more than one of them speaks up, you get double page views.

One thing people underestimate is how capable GA4 is out of the box. With Enhanced Measurement turned on, GA4 listens to the browser’s navigation events directly. Anytime the app calls pushState or replaceState—which Next.js does behind the scenes on every route change—GA4 immediately fires a page_view. No manual calls required, no router hooks needed, no guesswork.

But in the project I was looking at, there was also a React hook listening to the route changes using usePathname(). When pathname changed, it ran something like:

gtag("event", "page_view", { page_path: pathname });

So what happened? GA4 fired its page_view the moment the URL changed, and a few milliseconds later, the React effect fired its own. That’s all it took. Two hits for every navigation. Floodlight and Google Ads just followed the noise—they don’t know which event is “real”; they just fire when told to.

This wasn’t a bug. It was just too many helpers trying to be helpful.

The real solution came down to choosing who owns navigation detection. After debugging the flow and verifying what GA4 was already doing, I had two choices.

Option 1: Let GA4 handle it. If the URL accurately represents each “page” in your SPA—and in this case, it did—then GA4’s automatic tracking is the cleanest approach. I removed the manual page_view emitters, avoided GTM history triggers, and let GA4’s History API listeners run the show. Immediately, the duplicate hits disappeared. One navigation meant one page_view. Everything downstream matched cleanly.

Option 2: Take control manually. I’ve used this pattern on other projects where the URL doesn’t change but the user clearly moves through steps. In those cases, you turn off GA4’s auto-detection and send your own page views through a single, centralized dispatcher. The key is consistency. You can’t leave GA4 half-enabled; any hybrid setup will double-fire.

For this project, GA4-as-owner was the right fit. Once the manual code was removed, DebugView showed exactly one page_view per route change. Floodlight calmed down. Google Ads calmed down. And the numbers became trustworthy again.

If you ever want to sanity-check your own setup, don’t start with GA4’s UI. Open the Network tab in your browser and filter for GA4 requests. Every navigation should produce exactly one collect?v=2&en=page_view. If you see two, something else is chiming in.

The pattern I’ve seen across many SPA implementations is simple: pick one navigation authority. If your URLs reflect your app’s structure, let GA4 handle it. If not, disable GA4’s auto behavior and track manually. Just don’t mix and match. That’s where the duplicates live.