Your analytics or advertising <Script> fires on load — its network request appears in DevTools before the consent banner is even answered — which means a tracking vendor is running without a lawful basis and your site is out of compliance.
Triage: Confirming the Script Fires Pre-Consent
The defect is that the script executes unconditionally. Prove the ordering before changing code.
-
Load with a cleared consent state. Open the site in a fresh incognito window (no stored consent) and open DevTools → Network before navigating.
-
Filter to the vendor domain. Type the vendor host (e.g.
googletagmanager.com,connect.facebook.net) into the Network filter. If a request appears while the consent banner is still on screen and unanswered, the script is firing pre-consent. -
Confirm no update fires on rejection. Answer the banner with “reject.” If the vendor kept sending, or if a Consent Mode
updatetodeniednever appears in the console/dataLayer, the script is not being gated at all. -
Run the reproduction checklist:
Root Cause: The Component Renders Unconditionally
next/script injects and executes its script as soon as it is rendered at the strategy’s scheduled moment. If you render <Script src="…gtm.js" /> in a layout, React commits it on first paint and Next.js loads it — there is no built-in awareness of consent. The component’s presence in the tree is the trigger; nothing between the user’s choice and the injection gates it.
Compliance requires the inverse: the script must be absent from the render tree until the relevant consent category is granted, so injection is a consequence of consent rather than of page load. This is the client-side enforcement layer that sits under the broader model in architecting GDPR-compliant consent gating, applied to the specific mechanics of isolating scripts with the Next.js Script component. The gate is not a strategy value — no strategy prevents execution — it is conditional rendering.
The component’s render is not the boundary people expect it to be, which is the root of the defect: returning null from a component does not prevent a Script that was already rendered on the server from appearing in the HTML.
Resolution: A useConsent() Hook Gating the Script
Read the resolved consent state through a small client hook, then render <Script> only when the category it belongs to is granted. Because consent lives in the browser (via the CMP / consent bus), the hook must be SSR-safe: it returns a denied default on the server and during the first client render, then updates once the CMP resolves.
1. The SSR-safe consent hook. It subscribes to your consent source and never assumes window exists at module load.
// hooks/use-consent.ts
'use client';
import { useEffect, useState } from 'react';
export type ConsentState = {
analytics: boolean;
ads: boolean;
};
// Denied default: correct on the server and before the CMP resolves.
const DENIED: ConsentState = { analytics: false, ads: false };
export function useConsent(): ConsentState {
const [consent, setConsent] = useState<ConsentState>(DENIED);
useEffect(() => {
// Subscribe to the single consent source of truth (CMP / consent bus).
// subscribe() fires immediately with the current state, then on every change.
const unsubscribe = window.consentBus?.subscribe((state: ConsentState) => {
setConsent({ analytics: !!state.analytics, ads: !!state.ads });
});
return () => unsubscribe?.();
}, []);
return consent;
}
2. Gate the <Script> on the granted category. The component is a Client Component ('use client') because it uses a hook. When consent is denied, the <Script> is simply not in the tree, so nothing loads.
// components/analytics-gate.tsx
'use client';
import Script from 'next/script';
import { useConsent } from '@/hooks/use-consent';
export function AnalyticsGate() {
const { analytics } = useConsent();
// Not rendered until analytics consent is granted → no request fires.
if (!analytics) return null;
return (
<Script
src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
strategy="afterInteractive"
onLoad={() => {
// Promote Consent Mode now that the category is granted.
window.gtag?.('consent', 'update', { analytics_storage: 'granted' });
}}
/>
);
}
3. Pair with a denied default stub. Conditional rendering stops the gated vendor, but Google’s own Consent Mode needs a denied default set before any Google tag could ever run. Emit it with a beforeInteractive script in the root layout, exactly as in the Script-component guide, so the baseline is “denied” and the update above is the only thing that grants.
// app/layout.tsx — denied default before hydration; gate mounts later.
import Script from 'next/script';
import { AnalyticsGate } from '@/components/analytics-gate';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<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>
</head>
<body>
{children}
<AnalyticsGate />
</body>
</html>
);
}
Because useConsent returns DENIED during SSR and the first client render, the server and client markup match — the gate renders null on both — so there is no hydration mismatch. When the CMP resolves and the user has granted analytics, the state flips, the component re-renders, and only then does <Script> inject.
Verification
Load the site in a fresh incognito window with DevTools → Network open and filtered to the vendor domain. The decisive check: zero requests to the vendor host before the banner is answered, a single request appearing only after you accept the relevant category, and — in the console or GA4 DebugView — a Consent Mode update with analytics_storage: 'granted' firing at that same moment. Reject instead, and confirm the vendor request never appears and the state stays denied. Reload after accepting and confirm the persisted consent lets the script load without re-prompting.
The hook itself has one requirement that is easy to miss and produces an intermittent leak: it must distinguish three states, not two.
A related failure is worth naming because it survives an otherwise correct gate: a vendor loaded through a tag manager is not covered by the component that gates the tag manager. Gating the container prevents the container from loading, but once it does load it injects its own tags on its own schedule and by its own rules — your React tree has no visibility into them at all. Configure the container’s internal consent settings as well, and verify from the network panel rather than from the component tree, which will look correct either way.
A second-order effect of gating is worth planning for before it surprises someone: your analytics volume drops, immediately and permanently, by whatever share of visitors decline. That is the correct outcome — those sessions were never lawfully measurable — but if nobody warns the team that owns the dashboards, the drop is read as a bug in the release and someone is asked to “fix” it. Annotate the release in your analytics tool and record the expected magnitude beforehand, so the step change is explained rather than investigated.
Common Pitfalls
- Rendering
<Script>then trying to “block” it with an onLoad guard. By the timeonLoadruns, the script has already downloaded and executed — the compliance breach already happened. The gate must prevent rendering, not react after load. - Reading consent without an SSR-safe default. Touching
window.consentBusat module scope or in the render body throws on the server and causes a hydration mismatch. Read it insideuseEffectand default to denied, as shown. - Gating the vendor tag but not setting a denied Consent Mode default. Without the
beforeInteractivedefaultstub, Google tags treat unset as granted; conditional rendering alone leaves a gap. Pair the gate with the denied default so the baseline is compliant.
Frequently Asked Questions
Does this apply equally to the app router and the pages router?
The hazard is the same in both, though it is easier to introduce in the app router because server components are the default there — a Script in a component you did not explicitly mark as client-side is evaluated on the server, and a consent condition around it is evaluated with no consent state.
In the pages router the equivalent trap is rendering the tag during getServerSideProps-driven output or in _document, both of which run server-side. The rule that holds in both is the same: a consent-gated tag must be injected from code that only ever runs in the browser.
What should render in the gap while consent is pending?
Whatever preserves the layout, and nothing that contacts the vendor. For an invisible tag such as analytics there is nothing to render and the gap is unobservable. For a visible embed, render a placeholder with the embed’s exact dimensions so the eventual injection does not shift the page — the same reservation discipline a facade uses.
Avoid rendering a spinner. Consent may never be granted, in which case the spinner never resolves, and a permanent loading state is a worse experience than a static placeholder that explains why the content is not there.