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.

The two guards that make this correct do different jobs, and using only one of them leaves a different bug in each case.

Two guards, two different bugs prevented The environment guard, import.meta.client, prevents the entry being evaluated during server rendering, so the tag cannot be serialised into the initial HTML. The reactivity guard, a computed or watched consent ref, ensures the entry is re-evaluated when the decision changes rather than only once during setup. Using the environment guard alone produces a tag that never appears after a later grant; using the reactivity guard alone produces a tag serialised into the server response. import.meta.client keeps the entry out of the server render alone → never injected after a later grant the tag simply never appears a reactive consent ref re-evaluates when the decision changes alone → serialised into the server HTML the leak this page is about Both guards, always. Each one alone fails in the direction the other protects.

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.

It is worth knowing exactly where to look when verifying, because Nuxt’s own devtools and the element inspector both show the hydrated result rather than what was sent.

Check the response, not the DOM Three verification surfaces. The element inspector shows the DOM after hydration, so a tag correctly removed on the client is already gone and a leak is invisible. Nuxt devtools shows the resolved head state, which is also post-hydration. Viewing the raw response with view-source or curl shows exactly what the server sent, which is the only surface where a server-side leak appears. element inspector post-hydration DOM a leak is already gone Nuxt devtools resolved head state also post-hydration view-source / curl exactly what was sent the only honest check A compliance audit reads the third surface. Test the thing that will be tested.

One more property is worth confirming while you are in the code: the entry must be removable, not merely conditional. If the visitor withdraws consent mid-session, the reactive entry stops rendering the tag, but the script that already executed is still resident and still running — reactivity governs the markup, not the runtime. Pair the head gate with the vendor teardown call, or the withdrawal only takes effect on the next navigation.

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.
Where the gate belongs relative to the rest of the app A layered view. The consent platform emits a decision. A single composable normalises it into a reactive ref. Every gated useHead entry and every gated component reads that one ref. Nothing else in the application reads the platform directly, so replacing the platform touches one file. the consent platform useConsent() — one composable, one ref useHead entries gated components route middleware

Frequently Asked Questions

Should the gated tag live in useHead or in a plugin?

Use useHead when the tag belongs to a specific page or layout, and a client-only plugin when it belongs to the whole application. The distinction matters for cleanup: a useHead entry is scoped to the component that registered it and is removed when that component unmounts, which is the behaviour you want for a page-specific embed.

For an application-wide tag, a plugin gives you a single registration and a single place to react to consent changes, rather than a gate repeated in every layout. Mark it client-only so it never participates in the server render at all, which removes the leak by construction rather than by guard.

Why does the tag appear twice after a client-side navigation?

Because the entry was registered again without the previous one being released. useHead deduplicates by key, so the usual fix is to give the entry a stable key — without one, two registrations of the same script are treated as two different entries and both are rendered.

The other common cause is registering inside a watcher that fires on every consent evaluation rather than only on change. Watch for the transition rather than the value, or move the registration outside the watcher and let reactivity handle the rendering, which is what the resolution above does.

Up: Consent-Gating Third-Party Scripts in Nuxt