I built this site for myself, which means I kept cutting corners I'd never cut for a client. The GA4 tag has been running since launch, firing page views for every visitor with zero consent dialog. That's not legal under GDPR for anyone in the EU, and since I live in Greece, my own site was the most obviously non-compliant thing I maintain. I fixed it today.
Here's exactly what I did and what bit me on the way.
The requirement: opt-in, not opt-out
GDPR Article 7 requires freely given, specific, informed, unambiguous consent before you set non-necessary cookies. Analytics cookies — including GA4's _ga and _gclxxxx identifiers — are not necessary. The default must be denied. The user opts in; you don't ask for forgiveness later.
That rules out the "we set the cookie, but you can opt out in the footer" approach that half the internet still uses. Opt-out banners are not compliant. The default state must be no tracking.
Why Google Consent Mode v2 instead of conditional script injection
The obvious approach is: don't load the GA4 <Script> tag until the user consents. Dynamically inject it after acceptance. Clean, simple.
I didn't do that, because the GA4 tag was already wired into app/layout.tsx and I wanted to keep that intact. Conditional injection also creates a second problem: if the user has already consented on a previous visit, you have to load the script on mount, check localStorage, then fire the page view — and you end up with race conditions between Next.js hydration and your dynamically created <script> element.
Google Consent Mode v2 solves this differently. You set analytics_storage: 'denied' before gtag.js loads. GA4 still initialises, but it fires no cookies and sends no identifying data. When the user consents, you call gtag('consent', 'update', ...) and GA4 starts tracking from that point forward. No duplicate tags, no hydration races, no flash.
Setting the default before gtag.js loads
The consent default has to run before the GA4 script. In Next.js 15 App Router, that means a beforeInteractive inline script at the top of <head>:
// app/layout.tsx
import Script from 'next/script';
<Script id="consent-default" strategy="beforeInteractive">
{`
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
});
`}
</Script>
wait_for_update: 500 tells GA4 to hold its first network request for 500ms in case a consent update arrives quickly — useful when the user has already consented and you're restoring that state from localStorage on mount.
The GA4 init itself uses send_page_view: false so the initial page view does not fire until consent is explicitly granted:
gtag('config', process.env.NEXT_PUBLIC_GA_ID!, { send_page_view: false });
The banner: categories, buttons, and withdrawal
The banner has two categories. Necessary cookies (session, CSRF) are always on — the checkbox is disabled and checked. Analytics is off by default. Three action paths:
- Accept all — grants analytics, fires the deferred page view, stores consent.
- Reject all — stores the rejection, dismisses the banner.
- Customise → Save preferences — reads the individual toggles, grants or denies accordingly.
Consent state persists to localStorage under the key klawsfx_cookie_consent. On mount the banner checks this key; if a valid stored choice exists, it restores the consent state via gtag('consent', 'update', ...) and skips showing the banner.
The update + deferred page view call looks like this:
function grantAnalytics() {
gtag('consent', 'update', { analytics_storage: 'granted' });
gtag('config', process.env.NEXT_PUBLIC_GA_ID!, { send_page_view: true });
}
GDPR also requires that withdrawing consent is as easy as granting it. So there's a "Cookie preferences" link in the footer. Clicking it clears klawsfx_cookie_consent from localStorage and reloads the page, which causes the banner to reappear and the default-denied state to re-apply. One line of JavaScript, no tricks.
Banner copy is DB-backed
Hard-coding the cookie policy text into a React component felt wrong. Policy wording changes — and when it does, the last thing I want is to dig into source, update a string, and redeploy.
The banner pulls its category descriptions and policy blurb from a CookieSettings singleton row in the Postgres database via Prisma. There's an admin "Cookies" tab that lets me edit the copy directly. The consent logic itself stays in code; only the human-readable text lives in the DB.
The Vercel deploy that broke immediately
I added a CookieSettings model to schema.prisma, ran prisma migrate dev locally, and the build worked fine. Pushed to main, Vercel started a deploy — and it failed with a TypeScript error on a generated Prisma type that didn't exist.
The problem: Vercel caches node_modules between deploys. The cached @prisma/client didn't include the new model because prisma generate hadn't run in that environment yet. Local builds were fine because I'd run it manually.
The fix is to make prisma generate part of the build:
// package.json
{
"scripts": {
"postinstall": "prisma generate",
"build": "prisma generate && next build"
}
}
Both postinstall (for cold installs) and the explicit prefix in build (for Vercel's cached-install path) are needed. One or the other isn't enough — I had postinstall already from a previous migration and it wasn't running because Vercel skipped the install step on a cache hit.
A note on CSP
The consent default is an inline script, so your Content-Security-Policy has to allow it. If you run a nonce-based CSP, generate a nonce in middleware and pass it to the <Script> — that's the stricter setup, and the one I'd reach for on a client build. Either way the snippet is static, first-party, and pulls in no external data, so it stays a small, well-scoped exception rather than a hole.
What I'd do differently on a client site
On a commercial site I'd go further:
- A nonce-based CSP for inline scripts, generated per-request in Next.js middleware.
- Server-side consent logging if the client needs an audit trail (GDPR Article 7.1 says you must be able to demonstrate consent was obtained).
- A more granular category list if the site runs marketing pixels alongside analytics.
For a portfolio site that runs exactly one analytics tag and no ads, this implementation covers the legal requirement cleanly.
FAQ
Does Google Consent Mode v2 actually prevent GA4 from tracking before consent?
Yes. When analytics_storage is set to 'denied', GA4 does not set cookies and does not send user identifiers. It may still send cookieless, aggregate signals if you have modelling enabled in your GA4 property — check your GA4 settings if you need hard zero-data mode.
Can I just not load the GA4 script until the user consents instead of using Consent Mode? You can, and it works. The trade-off is complexity: you need to handle the returning-visitor case (load script immediately if consent exists in localStorage), avoid hydration races, and ensure you're not double-initialising the tag. Consent Mode keeps one tag, one initialisation path, and a simple state flip.
What counts as a "necessary" cookie under GDPR? Necessary cookies are those required for the site to function — session tokens, CSRF tokens, load-balancer stickiness. Analytics, personalisation, and advertising cookies are not necessary regardless of how the privacy policy describes them. If the site works without the cookie, it is not necessary.
Does this approach work if I later add advertising or remarketing tags?
Yes. You add the relevant signals (ad_storage, ad_user_data, ad_personalization) to your consent update call and map them to your own consent categories. The Consent Mode default already denies all four; you just need to add UI controls for the additional categories and update the gtag('consent', 'update', ...) call accordingly.
