You called useHead({ script: [{ src: '...' }] }) to add a third-party tag, but the script is now baked into the server-rendered HTML and executes before the consent banner is even answered — you need it injected only on the client, and only after the user grants consent.

Verify the symptom precisely before changing anything — a leaking SSR script and a client script that fires too early have different fixes.

  1. Request the raw server HTML: curl -s https://your-site/ | grep -i -E 'gtag|pixel|your-vendor-domain'. If the vendor <script> tag appears here, the entry is being serialized into the SSR <head> — an SSR leak.
  2. Open DevTools → Network, disable cache, and hard-reload with the consent banner not yet accepted. If a request to the vendor domain fires before you click “Accept,” the gate is not holding.
  3. In DevTools → Application → Local Storage, clear any stored consent key so you reproduce the true first-visit state.
  4. Set a Network breakpoint or a console.trace() in a beforeRequest proxy, or simply watch the initiator column — an initiator of (index) / document means the tag shipped in the HTML; an initiator of your bundle means it was injected client-side (possibly still too early).

Reproduction checklist:

Root Cause: useHead Runs on Both Server and Client

useHead is isomorphic. It is Nuxt’s binding over the Unhead engine, and it executes during both the server render and client hydration. When you pass a plain, non-reactive object, its script entries are collected during SSR and serialized directly into the <head> of the HTML document Nuxt returns. The browser then parses and executes that <script> on first paint — long before any consent decision exists, and with no way for a later watch to “un-inject” it.

This is the same dual-render contract described in the guide on consent-gating third-party scripts in Nuxt: the injection must be both reactive (so it can appear later, on grant) and environment-guarded (so it never renders during SSR). A plain object literal satisfies neither. Passing a computed makes the entry reactive, and checking import.meta.server inside that computed keeps it out of the serialized payload. Miss either half and the tag leaks — a direct GDPR prior-consent violation, since the vendor executes without a lawful basis, and the mechanism is governed by the upstream GDPR-compliant consent gating architecture.

Resolution: A Reactive, Client-Only useHead Entry Gated on a Consent Ref

Replace the plain object with a computed that returns an empty array until two conditions hold — we are on the client, and consent is granted. A watch on the consent ref is not even required for the head itself, because useHead already tracks the computed; the watcher is only useful for side effects like firing a configuration call after load.

<!-- components/GatedPixel.vue -->
<script setup lang="ts">
import { computed, watch } from 'vue'

// Shared, SSR-safe reactive consent state (see the useConsent composable).
const consent = useConsent()
const granted = computed(() => consent.value.marketing)

// The reactive head entry. useHead re-evaluates this whenever `granted` changes.
useHead({
  script: computed(() => {
    // Guard 1 (environment): never render during SSR, so the tag stays out of
    // the serialized HTML payload entirely.
    if (import.meta.server) return []
    // Guard 2 (consent): nothing until the user grants marketing consent.
    if (!granted.value) return []

    return [{
      src: 'https://cdn.example-pixel.com/pixel.js', // marketing pixel vendor URL
      async: true,
      // A stable key lets Unhead dedupe the entry and prevents a second
      // insertion on hot-module reload or repeated renders.
      key: 'marketing-pixel',
      // tagPriority keeps ordering deterministic relative to other head tags.
      tagPriority: 'low',
    }]
  }),
})

// Optional: run vendor configuration exactly once, after consent flips true.
watch(granted, (isGranted) => {
  if (import.meta.client && isGranted) {
    // Safe to reference window here — this only runs client-side, post-grant.
    window.examplePixel?.('init', 'PIXEL_ID') // your pixel account ID
  }
}, { immediate: false })
</script>

<template>
  <span aria-hidden="true" />
</template>

The cleaner alternative — and the recommended default when the vendor is a plain external script — is the @nuxt/scripts useScript composable, which owns the SSR-safety and dedupe logic and accepts a reactive consent trigger directly. There is no manual computed, no environment guard to forget:

<!-- components/GatedPixelScripts.vue -->
<script setup lang="ts">
import { computed } from 'vue'

const consent = useConsent()
const granted = computed(() => consent.value.marketing)

// useScript defers the actual <script> insertion until `trigger` resolves truthy,
// and never renders it during SSR. The module keys by `src` to dedupe.
const { onLoaded, status } = useScript(
  { src: 'https://cdn.example-pixel.com/pixel.js', async: true },
  { trigger: granted }, // reactive consent ref = the gate
)

onLoaded(() => {
  window.examplePixel?.('init', 'PIXEL_ID') // runs once the script has loaded
})
</script>

<template>
  <!-- `status` is 'awaitingLoad' | 'loading' | 'loaded' | 'error' — handy for UI -->
  <span aria-hidden="true" :data-pixel-status="status" />
</template>

Both paths produce the same guarantee: the tag is absent from SSR HTML, absent from the client until consent, and injected exactly once on grant.

Verification

Request the raw HTML and confirm the tag is gone, then confirm it appears only after consent:

# 1. Build and serve production output, then check the SSR payload is clean.
nuxi build
node .output/server/index.mjs &
curl -s http://localhost:3000/ | grep -c 'cdn.example-pixel.com'   # must print 0

Then in the browser: with the consent banner unanswered, the Network panel shows zero requests to cdn.example-pixel.com. Click “Accept,” and a single request to that domain appears, initiated by your bundle (not the document), with document.head now containing exactly one <script data-hid> carrying your marketing-pixel key. A Lighthouse run before consent shows the pixel contributing 0 ms to Total Blocking Time and absent from the third-party audit.

Common Pitfalls

  • Passing a plain object instead of a computed. useHead({ script: [{ src }] }) with a literal array serializes on the server every time. Only a reactive source (computed/ref) can appear after initial render — and only an import.meta.server guard keeps it out of the SSR payload.
  • Omitting the stable key. Without a key, Unhead cannot match the client entry to any server placeholder and may insert the script twice after hydration, producing duplicate vendor requests and double-counted events.
  • Reading window or the CMP inside the computed unguarded. The computed still evaluates during SSR (returning []), so any window.__tcfapi or localStorage access there throws window is not defined on the server. Keep all browser-API reads inside the consent composable’s .client plugin, and let the computed read only the reactive ref.

Up: Consent-Gating Third-Party Scripts in Nuxt