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.

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.

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.

Up: Isolating Scripts with the Next.js Script Component