PERFMETRIX — in data we trust
All articles
Consent Mode3 August 2026·9 min read

Consent Mode on an SPA: why tags fire twice, or not at all

The consent default has to run before React hydrates, a Once-per-page tag fires once for the whole visit, and two things are probably counting your route changes.

DeBy Denis · Perfmetrix

Short answer: put the gtag('consent','default', ...) call in a plain script tag in your App Router layout's head, not in a React component — it has to run before hydration, and anything React mounts runs after. Set your tags to fire Once per event, never Once per page: an SPA loads one page for the whole visit, so a Once-per-page tag fires for the first route and then never again, whatever the visitor consents to later. And pick exactly one thing to count route changes, because GA4's enhanced measurement is already doing it.

The consent default has to run before React exists

Google is blunt about ordering. "The order of the code here is vital. If your consent code is called out of order, consent defaults won't work", and the default command must run "on every page of your site before any commands that send measurement data" (Google, Set up consent mode on websites, checked 3 August 2026).

On a React app, "before" isn't a style preference. It's a race you lose by default. A useEffect runs after hydration. A client component's body runs during render, which is still after the browser has parsed the document's own scripts. Anything the framework mounts is downstream of anything sitting in the head.

So the default goes in the head as a synchronous script, before the container:

// app/layout.tsx
const consentScript = `(function(){
  window.dataLayer=window.dataLayer||[];
  function gtag(){dataLayer.push(arguments)}
  window.gtag=window.gtag||gtag;
  gtag('consent','default',{
    ad_storage:'denied', ad_user_data:'denied', ad_personalization:'denied',
    analytics_storage:'denied', personalization_storage:'denied',
    functionality_storage:'granted', security_storage:'granted',
    wait_for_update:500
  });
})();`;

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <script dangerouslySetInnerHTML={{ __html: consentScript }} />
        {/* the container loads after this, never before */}
      </head>
      <body>{children}</body>
    </html>
  );
}

wait_for_update earns its place here. Google's guidance: "If your banner loads asynchronously, it might not always run before your Google tags. To handle such situations, specify wait_for_update along with a millisecond value to control how long to wait before data is sent." On an SPA the banner is a React component, so it is always asynchronous relative to the head. Half a second is the value in Google's own example.

The second half of that script matters as much and gets skipped: read the returning visitor's stored choice in the same block and call gtag('consent','update', ...) immediately. Google's instruction is to "persist their choice and call the update command accordingly on subsequent pages". If you only replay the stored choice from React, every returning visitor spends the first few hundred milliseconds of every visit in the denied state they already rejected.

@next/third-parties/google loads the container after hydration

Next's own documentation states it plainly: the GoogleTagManager component "fetches the original inline script after hydration occurs on the page", and GoogleAnalytics likewise "fetches the original scripts after hydration" (Next.js, Third-party libraries guide, as shipped with next 16.2.10, checked 3 August 2026).

That is good news for the ordering problem, and it is the thing people misread. Loading the container after hydration means your head-level default has an enormous head start. It does not mean the package handles consent. There is no consent prop, no default state, nothing. The full option list is gtmId, gtmScriptUrl, dataLayer, dataLayerName, auth and preview.

Where it genuinely helps:

  • Keeping the container off the critical path. After-hydration loading is the whole point.
  • Events from components via sendGTMEvent and sendGAEvent, which push to the data layer without you touching window.
  • A tagging server. gtmScriptUrl points the container at your own domain, and the docs note gtmId "can be omitted when gtmScriptUrl is provided" to support Google tag gateway.

Where it doesn't: the consent default, the banner, the stored choice, and the update call. All four stay yours. If you put the default in a component because the container is in a component, both now run after hydration and the ordering is whatever React decides that render.

"Once per page" means once per page load, and an SPA has one

Google defines the firing option exactly: "The tag will fire only once, when the page loads" (Tag Manager Help, Tag firing options, checked 3 August 2026). Compare "Once per event": "The tag will fire only once when the specified event occurs."

An SPA loads the page once. Every route after that is a pushState call, which is what the History Change trigger listens for — it "will fire a tag when the URL fragment changes or when a site uses the HTML5 pushstate API" (Tag Manager Help, History change trigger). A history change is an event. It is not a page load.

So a tag on a History Change trigger set to Once per page fires for the first route change of the visit and is finished for good. Not for the session. For the tab.

Now add consent, because that's where it stops looking like a firing-option problem. The usual sequence: visitor lands with everything denied, clicks through two or three routes, then accepts. A tag configured with additional consent checks "will only fire if the status of all specified consent types are 'granted' when the tag is triggered" (Tag Manager Help, Tag Manager consent mode support). Note the tense — the check happens when the tag is triggered.

What Google does not document is the combination. On 3 August 2026 I read the consent mode support page, the tag firing options page and the Tag Assistant consent debugging guide, and none of them state what happens to a Once-per-page tag that was triggered while consent was denied and later has consent granted. There's no published statement either way, so treat any confident answer you're given — including one that says it works fine — as untested.

The rule doesn't depend on resolving it: on an SPA, don't set Once per page on anything. Use Once per event. You give up nothing, because the "page" that option counts is your visitor's entire visit.

Two things are counting your route changes

GA4 is already tracking your route changes, and almost nobody checks before adding their own. History-change triggers and GA4's built-in page-view measurement listen to the same browser events, so wiring up the first without switching off the second gives you duplicate pageviews. The enhanced measurement page-view option carries an advanced setting that, in Google's words, "listens for pushState, popState, and replaceState" (Analytics Help, Enhanced measurement, checked 3 August 2026). Next's documentation says the same thing from the other end: "client-side navigations between Next.js routes will send pageview data without any configuration."

Next's docs then give the warning that the tutorials leave out: "If you decide to manually send pageview events, make sure to disable the default pageview measurement to avoid having duplicate data."

The standard SPA recipe — a useEffect on the pathname that pushes a page_view — is exactly that manual send. Leave enhanced measurement's history option on, which is the default, and every route now counts twice.

The consent-flavoured version is worse to diagnose. Your manual pageview usually sits in a GTM tag with a consent condition, and enhanced measurement's doesn't. So while consent is denied you see one pattern, and from the moment the visitor accepts you see another, with route counts stepping up at the click. That reads as a consent fault in the reports. It's a duplication fault, and the fix is to pick one counter, not to loosen the consent condition. Sudden shifts in GA4 volumes around a banner have several other causes worth ruling out first.

Preview mode shows you one container load, so test the route change by hand

Google's verification steps are built around a single container load. For the default: "In the Summary, select the earliest Consent event. In the API Call section, check that the following parameters were set: ad_storage, ad_personalization, ad_user_data, analytics_storage." For the update: "select the most recent Consent event" and check the same four were updated (Google, Troubleshoot consent mode with Tag Assistant, checked 3 August 2026).

Both checks pass on an SPA as soon as your first route is correct. Neither says anything about route four, which is where SPA setups actually fail.

Verify against requests instead. Google documents the parameter to watch: "The gcs parameter is used to transmit the ad_storage and analytics_storage parameters, indicating the user's consent choice regarding the storage of advertising and analytics cookies" (Google, Consent mode overview). Open the network panel, filter for your collection endpoint, and walk it:

  1. Fresh load, banner untouched. Requests should exist, and carry a gcs value showing storage denied. No requests at all means your tags are blocked rather than consent-aware, which is a different setup than the one you think you have.
  2. Two more routes, still untouched. One request per route. Zero means Once per page. Two means you're double-counting.
  3. Accept. An update fires, and gcs changes on the next request.
  4. One more route. The tags that were supposed to start after consent have started, and the count per route is still one.

Step 4 is the one that catches everything in this post, and it is the one no preview panel runs for you.

Steps 1 to 3 are what the Consent Mode v2 detector automates: it loads your live site, checks whether a default state fires before your tags, and reports whether ad_user_data and ad_personalization are genuinely present rather than assumed. Step 4 stays manual for now — a route change needs a human deciding which route.

What to do

Default in the head, container after it, wait_for_update set, stored choice replayed in the same block. Once per event on every tag. One counter for route changes, chosen deliberately. Then walk the four steps above on the live site rather than trusting the preview panel.

Get that right and the choice between Basic and Advanced is a real decision rather than a coin flip on a broken foundation. Get it wrong and the mode you picked is irrelevant, which is the same conclusion as a CMP whose auto-blocking can't see the tags GTM injects: the wiring decides the outcome, not the product.

If you'd rather have someone do the wiring and hand you the verification, that's the work.

Sources

  1. 1.Google — Set up consent mode on websites (code ordering, wait_for_update) · Checked 2026-08-03
  2. 2.Google — Consent mode overview (the gcs parameter, consent state pings) · Checked 2026-08-03
  3. 3.Google — Troubleshoot consent mode with Tag Assistant · Checked 2026-08-03
  4. 4.Tag Manager Help — Tag firing options (Once per page, Once per event) · Checked 2026-08-03
  5. 5.Tag Manager Help — History change trigger · Checked 2026-08-03
  6. 6.Tag Manager Help — Tag Manager consent mode support (additional consent checks) · Checked 2026-08-03
  7. 7.Next.js — Third-party libraries guide, @next/third-parties (docs shipped with next 16.2.10) · Checked 2026-08-03
  8. 8.Analytics Help — Enhanced measurement (page views on browser history events) · Checked 2026-08-03

Find out what your site leaks — in 30 seconds

Run the free consent checker on your own domain, or book a call and we'll walk your setup together.