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.

The defect is that the script executes unconditionally. Prove the ordering before changing code.

  1. Load with a cleared consent state. Open the site in a fresh incognito window (no stored consent) and open DevTools → Network before navigating.

  2. 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.

  3. Confirm no update fires on rejection. Answer the banner with “reject.” If the vendor kept sending, or if a Consent Mode update to denied never appears in the console/dataLayer, the script is not being gated at all.

  4. 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.

Rendering nothing is not the same as never rendering Two placements. A Script component rendered conditionally inside a server component evaluates the condition on the server, where no consent state exists, and the tag is emitted into the HTML. The same component rendered inside a client component that returns null until consent resolves never reaches the server output at all, because the client component renders nothing during the server pass. condition evaluated on the server no consent state exists there the tag lands in the HTML and is fetched during parsing in a 'use client' component renders nothing during the server pass injected only after the hook resolves nothing to remove afterwards The fix is structural: gated tags belong in client components, not behind server-evaluated conditions.

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 boolean hook cannot express "not yet" A hook returning a boolean has only granted and not-granted, so the initial render before the consent platform resolves is indistinguishable from a refusal — and code written to render the tag when the value is not false will render it during that window. A hook returning granted, denied or pending makes the third state explicit, so the tag is rendered only on the first. boolean false means "no" and "not yet" the ambiguous window leaks 'granted' | 'denied' | 'pending' render the tag only on granted pending renders the placeholder The same three-state rule applies wherever consent is read; the framework does not change it.

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 time onLoad runs, 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.consentBus at module scope or in the render body throws on the server and causes a hydration mismatch. Read it inside useEffect and default to denied, as shown.
  • Gating the vendor tag but not setting a denied Consent Mode default. Without the beforeInteractive default stub, Google tags treat unset as granted; conditional rendering alone leaves a gap. Pair the gate with the denied default so the baseline is compliant.
Verifying the gate from outside the app Three checks, in order. Fetch the page with curl and grep for the vendor hostname — the raw HTML must not contain it. Load the page with the network panel filtered to the vendor origin and without answering the banner — there must be no requests. Accept, and confirm the request appears within the consent-to-execution budget. 1 · raw HTML curl | grep vendor-host must find nothing 2 · pre-consent network filter to the vendor origin must stay empty 3 · post-consent request appears promptly inside the 100 ms budget The first check is the one an auditor runs, and the only one that survives a hydration difference.

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.


Up: Isolating Scripts with the Next.js Script Component