Consent-first analytics in Next.js

How I wired GA4, Google Tag Manager and self-hosted Umami with Consent Mode v2 in Next.js, plus the GTM consent bug that silently dropped every first visit.

Prabir Singh, profile photoPrabir Singh·
·Updated ·10 min read
Laptop displays equal Decline and Accept consent buttons beside an analytics dashboard and handwritten testing notes.
In this article
  1. 01Two jobs, two tools
  2. 02Consent Mode v2 in plain terms
  3. 03The banner: two categories, equal weight
  4. 04The bug that ate every first visit
  5. 05Small things that keep the data honest
  6. 06Test the promises in the network panel
  7. 07Frequently asked questions
  8. 08Respect is a feature

Analytics can mislead in two directions: a banner can promise more privacy than the tags deliver, and a dashboard can look like a complete census when it only reflects people the tracker could observe.

I rebuilt the analytics on this site in a day. Cookieless aggregate measurement, Google Analytics only for people who say yes, and a Tag Manager container ready for ad pixels later. Along the way I hit a bug that would have zeroed out every first-time visitor in GA4, and nothing in the console said a word about it.

Here's the whole setup, and the trap.

Two jobs, two tools

Analytics is really two jobs pretending to be one.

The first job is counting. How many people came, from where, to which pages. Aggregate counts can be useful without setting tracking cookies. That does not make every implementation anonymous or exempt from privacy obligations.

The second job is understanding. Which campaign brought the founder who booked a call, which article they read first, which ad was worth the money. In this setup, I gate Google analytics and advertising measurement behind separate consent choices.

So I stopped asking one tool to do both.

JobToolCookiesNeeds consent
Aggregate visitsUmami, self-hostedNone by defaultAssess configuration and applicable rules
Behaviour and attributionGoogle Analytics 4_gaYes, Analytics
Ad measurement, laterMeta, Google Ads, LinkedIn via GTM_fbp, _gcl and friendsYes, Marketing

Why self-hosted Umami

Umami runs in a container next to the site, sets no cookies, and doesn't store IP addresses. Cookieless does not automatically mean consent-exempt. Assess what the configured tracker accesses, what you collect, and the rules applying to your audience. Counts can also miss blocked scripts, failed requests and Do Not Track visitors. Hosting it myself costs a few hundred megabytes of RAM on a server I already pay for. That's the jugaad version of a privacy-friendly analytics subscription.

Why GA4 still earns a place

Umami tells you that something happened. GA4, connected to Search Console and ad platforms, tells you why it might have. For a business that will run campaigns, that link is worth the extra consent step.

Consent Mode is Google's way of letting tags know what a visitor allowed. Four signals matter:

  • analytics_storage for analytics cookies
  • ad_storage for advertising cookies
  • ad_user_data for sending user data to Google for ads
  • ad_personalization for personalised advertising

The rule that makes everything else work: set them all to denied before any tag loads. In a Next.js App Router project, that means a tiny inline script in the root layout's <head>, running before Google Tag Manager:

// Plain JavaScript in an inline <script>. Runs before GTM: everything starts
// denied, then a stored choice is applied.
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag("consent", "default", {
  analytics_storage: "denied",
  ad_storage: "denied",
  ad_user_data: "denied",
  ad_personalization: "denied",
  wait_for_update: 500,
});

The same script reads the visitor's saved choice from localStorage and sends a consent update straight away. A returning visitor with a valid saved choice can reuse it. Missing, outdated or malformed preferences should fall back to denied; storage may be unavailable, so that path needs testing too.

Denied defaults are not the same as blocking every request

Google distinguishes basic consent mode, which blocks Google measurement tags until consent, from advanced mode, which can send cookieless pings while consent is denied. A denied default alone does not prove basic-mode behaviour. This site loads the GTM container separately from the measurement tags it controls; loading that container is itself a network request. The container configuration and observed requests must support the narrower promise that measurement tags are gated. Do not describe that as “nothing contacts Google before consent.”

The banner: two categories, equal weight

The banner asks one honest question with two separate answers: Analytics and Marketing. The first view offers Decline and Accept as identical outlined buttons. A quiet "Choose what to allow" link opens a checkbox for each category.

Equal weight isn't decoration. A big bright Accept next to a faint grey "manage options" is the oldest dark pattern on the web, and privacy regulators keep saying so, including the UK's ICO guidance on cookies. If the banner has to trick people into saying yes, the data you collect isn't worth much anyway.

A few details that matter more than they look:

  • It waits for the intro animation, so it never sits under a loader.
  • It never steals focus on a first visit. A "Cookie settings" link in the footer reopens it and moves focus inside.
  • Withdrawing consent stops future permitted collection and attempts to clear known first-party tracking cookies. Cookie domain/path rules matter; client code cannot erase every third-party cookie or data already sent.

The bug that ate every first visit

Here's the part that would have hurt.

Everything worked in testing. Accept, click a button, watch the event arrive in GA4. Then I tested the live site the way a real visitor behaves: land, read the banner, click Accept, then do nothing else. No page view reached GA4. Not one.

What actually happens

On the first page load, the GA4 tag in Tag Manager is triggered by "All Pages". At that moment consent is denied, so GTM blocks it. Correct.

Then the visitor clicks Accept. Consent becomes granted. And GTM does... nothing. It doesn't go back and fire tags it blocked earlier. My local test only looked fine because I also clicked a tracked button, and that event tag loaded GA4 on its own.

The fix

Push an event the moment the visitor chooses. In TypeScript, declare the two globals the inline script created, and call them optionally so nothing breaks when a blocker stops the tags loading:

declare global {
  interface Window {
    dataLayer?: unknown[];
    gtag?: (...args: unknown[]) => void;
  }
}
 
window.gtag?.("consent", "update", state);
window.dataLayer?.push({ event: "consent_update" });

Then give the GA4 Google tag a second trigger: a custom event called consent_update, alongside All Pages.

I made one more mistake on the way. I set the tag to fire "once per page" to avoid a double page view. GTM counted the blocked attempt at page load as that one firing, so the tag still never ran. Switching to "once per event" fixed it, but that setting alone does not prevent duplicates. Stored choices and cross-tab updates can also produce consent events, so verify initialisation and page-view behaviour separately.

After that: first visit, Accept, one page_view. Returning visitor, one page_view. Decline, nothing at all.

Small things that keep the data honest

Reset event parameters on every push. GTM's data model remembers keys from earlier pushes. If a "case study click" carries a slug and the next "contact click" doesn't, the contact click inherits the old slug. Every push now sets every known key, most of them to undefined.

Clear cookies from an older consent version. When the banner grew a Marketing category, old single-choice answers stopped counting. Anyone without a current choice gets their stale _ga cookies removed on load.

Keep Umami off localhost. The tracker is limited to the production domain, so development clicks never pollute real numbers.

Open the Content Security Policy deliberately. The policy lists exactly the hosts Tag Manager, GA4, Google Ads, Meta and LinkedIn need. Adding a pixel later is a container change, not a code deploy, and anything else stays blocked.

Test the promises in the network panel

A banner screenshot cannot prove what scripts send. Use a fresh browser profile and test the complete sequence:

ActionEvidence to inspect
First visit, no choiceConsent defaults and requests made before interaction
DeclineBlocked measurement tags; no later navigation bypass
Accept Analytics onlyAnalytics allowed; marketing remains denied
WithdrawFuture collection stops; removable first-party cookies are expired
Change choice in another tabThe open tab adopts the new choice
Browser storage unavailableDefaults remain safe and the current choice still behaves honestly

Check page views as well as tag firing: an initialisation trigger and a route-change trigger can accidentally count the same visit twice. Repeated consent events need deduplication or appropriately scoped triggers; “once per event” is not a duplicate-prevention strategy by itself.

Keep a small record of the container version, browser, consent state and observed requests. It makes campaign reporting more trustworthy and makes the privacy statement testable. Google documents the basic/advanced distinction; the network panel tells you which behaviour you actually deployed.

Frequently asked questions

There is no universal answer based on the product name. Check the actual configuration, device access, collected fields and applicable rules. The ICO guidance linked above covers technologies beyond cookies and currently notes that its detailed guidance is being revised. This article describes engineering choices, not a determination that a particular installation is exempt.

Choose deliberately and verify the implementation. Basic mode blocks Google measurement tags until consent; advanced mode can send cookieless pings under denied settings. Loading GTM and blocking measurement tags are separate actions, so describe the network behaviour precisely.

Why didn't GA4 record a page view after the visitor clicked Accept?

Google Tag Manager blocks a tag when consent is denied at page load, and it doesn't retry that tag when consent changes later. Push a custom event such as consent_update after the consent update, and add it as a trigger on the Google tag with "once per event" firing.

Is Google Tag Manager worth it for a small site?

If you only ever run GA4, probably not. Once ad pixels from Meta, Google Ads or LinkedIn enter the picture, GTM lets you add, change and gate them by consent without redeploying the site. The trade is that anyone with GTM access can inject scripts, so keep that access small.

Will my Google Analytics numbers be lower than Umami's?

They may be, but the gap is not a reliable decline rate. Consent, blocking, Do Not Track, session definitions, bot filtering and delivery failures can all change the totals. Compare like-for-like events and measurement windows before interpreting a difference.

Respect is a feature

Analytics that trick people produce numbers nobody should trust. Two tools, one honest banner and a few lines of code gave me useful aggregate counts and consented attribution, with limitations I need to keep visible. If you're building something and want measurement that a privacy-minded buyer won't flinch at, tell me what you're building. It's the same care I put into the stack behind this site and every web build, and it pairs well with traffic that turns into pipeline.

Was this useful?

No account needed. One like per browser; click again to undo.

ShareXLinkedIn
Prabir Singh, profile photo

Prabir Singh

Full-stack engineer in Mumbai. Twenty years in sales taught me why people buy; seven years of code taught me how to build what they'll pay for. I still read error messages the way I once read objections: as the most honest feedback of the day. More about me or start a project.

Keep reading