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.
Triage: Confirming the Script Leaks Before Consent
Verify the symptom precisely before changing anything — a leaking SSR script and a client script that fires too early have different fixes.
- 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. - 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.
- In DevTools → Application → Local Storage, clear any stored consent key so you reproduce the true first-visit state.
- Set a Network breakpoint or a
console.trace()in abeforeRequestproxy, 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.
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.
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 animport.meta.serverguard keeps it out of the SSR payload. - Omitting the stable
key. Without akey, 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
windowor the CMP inside thecomputedunguarded. Thecomputedstill evaluates during SSR (returning[]), so anywindow.__tcfapiorlocalStorageaccess there throwswindow is not definedon the server. Keep all browser-API reads inside the consent composable’s.clientplugin, and let thecomputedread only the reactive ref.
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.