Nuxt 3 renders every component twice — once on the server during SSR and again during client hydration — which turns naive third-party script injection into a compliance liability. A <script> tag placed in a component template, or a useHead call made unconditionally, is serialized into the server-rendered HTML and ships to the browser before any consent decision exists. The tag manager, analytics beacon, or advertising pixel then executes on first paint, in direct violation of GDPR’s prior-consent requirement and IAB TCF v2.2’s gating contract. This guide covers the exact Nuxt mechanics needed to hold every consent-gated script until a reactive consent signal grants it.
The core problem is timing and location: the script must be injected only on the client, only after a resolved consent grant, and it must survive hydration without duplicating or leaking into the SSR payload. Nuxt provides four building blocks that, combined, give a deterministic gate — the useHead composable for reactive head management, the @nuxt/scripts module (useScript) with first-class consent triggers, the plugins/ lifecycle for one-time consent bus wiring, and reactive shared state through useState or Pinia. This page assembles them into a single pattern and links the deep dive on the reactive useHead injection technique for the hardest case.
Prerequisites and When to Apply
Apply this pattern when your Nuxt 3 (or Nuxt 4) application meets one or more of these conditions:
- You load any script that requires consent under GDPR, ePrivacy, or TCF v2.2 — analytics, advertising pixels, session replay, heatmaps, chat widgets, or a tag manager container.
- The application uses SSR or
nuxt generate(SSG), so component render runs on the server and the head payload is serialized into HTML. Pure SPA mode (ssr: false) still needs the gate, but the SSR-leak failure surface disappears. - A consent management platform (CMP) resolves consent asynchronously after hydration — the near-universal case for OneTrust, Cookiebot, Didomi, and custom banners.
The upstream dependency is a working consent decision source. This page assumes the GDPR-compliant consent gating architecture already exposes a resolved consent event; Nuxt’s job is to translate that event into SSR-safe, reactive script injection. If you run more than one vendor, pair this with syncing consent states across multiple vendors so the Nuxt gate reads from one canonical state rather than polling each SDK.
Concept: How Nuxt’s Dual Render Model Governs Script Injection
Three Nuxt mechanics determine whether a gate is airtight. Understanding each contract precisely is what separates a compliant gate from one that leaks on SSR.
useHead is isomorphic and reactive. The useHead composable (from @unhead/vue, bundled with Nuxt) registers head entries that run on both server and client. When you pass a plain object, its script entries are serialized into the server-rendered <head>. When you pass a ref or computed, useHead tracks it reactively and mutates the DOM head when the value changes on the client. This reactivity is the lever: a script entry gated behind a computed that returns [] on the server and [{ src }] only after client-side consent will never appear in the SSR payload.
import.meta.client / import.meta.server are the environment guards. These are compile-time constants (replacing the older process.client/process.server) that Nuxt tree-shakes per bundle. Code inside if (import.meta.client) { … } is stripped from the server build entirely. Any consent-dependent side effect — reading localStorage, subscribing to window.__tcfapi, appending a script — must be guarded so it never executes during SSR, where window and the CMP do not exist.
Plugins run once per environment in a defined order. Files in plugins/ execute during app initialization. A plugin suffixed .client.ts runs only in the browser; it is the correct place to wire the CMP callback to Nuxt’s reactive state exactly once, rather than re-subscribing inside every component. Nuxt guarantees plugin execution before the app is mounted, so the consent bus is live before any component’s watch fires.
@nuxt/scripts formalizes the gate. The official @nuxt/scripts module wraps script loading in a useScript composable with a trigger option. A trigger can be a promise, an onNuxtReady hook, an element-visibility observer, or — critically — a manual trigger you resolve only after consent. It also exposes registry helpers (useScriptGoogleAnalytics, useScriptGoogleTagManager) whose consent option accepts a boolean, ref, or promise, deferring the actual <script> insertion until the signal is truthy. This is the least error-prone path because the module owns the SSR-safety and dedupe logic for you.
Implementation: A Reactive Consent Gate End to End
Step 1 — Expose consent as shared reactive state with useState
useState creates an SSR-friendly, cross-component reactive value with a stable key. Seed it false so the default (server and pre-consent client) is always “denied”:
// composables/useConsent.ts
import type { Ref } from 'vue'
export interface ConsentState {
analytics: boolean
marketing: boolean
}
export function useConsent(): Ref<ConsentState> {
// Keyed shared state — identical value on every component that calls this.
// Default is fully denied; only the client plugin ever flips a flag to true.
return useState<ConsentState>('consent', () => ({
analytics: false,
marketing: false,
}))
}
Step 2 — Wire the CMP to that state in a client-only plugin
The plugin subscribes to the CMP once. It runs only in the browser because of the .client.ts suffix, so window.__tcfapi and localStorage are always defined here:
// plugins/consent.client.ts
export default defineNuxtPlugin(() => {
const consent = useConsent()
// Restore a previously persisted decision to avoid a flash of un-gated scripts.
const stored = localStorage.getItem('consent_state')
if (stored) {
try { consent.value = { ...consent.value, ...JSON.parse(stored) } } catch { /* ignore malformed */ }
}
// Subscribe to the TCF CMP. The callback fires on every state change.
if (typeof window.__tcfapi === 'function') {
window.__tcfapi('addEventListener', 2, (tcData: any, success: boolean) => {
if (!success) return
// Act only on fully resolved consent states.
if (!['tcloaded', 'useractioncomplete'].includes(tcData.eventStatus)) return
const next = {
analytics: tcData.purpose?.consents?.[8] === true, // measure content performance
marketing: tcData.purpose?.consents?.[4] === true, // select personalised ads
}
consent.value = next
localStorage.setItem('consent_state', JSON.stringify(next))
})
}
})
Step 3 — Gate a script with @nuxt/scripts and a consent ref
Install the module (npx nuxi module add scripts) and pass a ref as the consent trigger. The module withholds the <script> element until the ref is truthy and guarantees it never renders during SSR:
<!-- components/Analytics.vue -->
<script setup lang="ts">
import { computed } from 'vue'
const consent = useConsent()
// A computed boolean the module watches. False on the server and pre-consent.
const analyticsGranted = computed(() => consent.value.analytics)
// useScript defers injection until `consent` resolves truthy, on the client only.
const { onLoaded } = useScript(
{
src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX', // your GA4 measurement ID
async: true,
},
{
// Consent-aware trigger: the script is not inserted until this is truthy.
trigger: analyticsGranted,
},
)
onLoaded(() => {
// Runs once the vendor script has actually loaded — safe to configure here.
window.dataLayer = window.dataLayer || []
function gtag(...args: unknown[]) { window.dataLayer.push(args) }
gtag('js', new Date())
gtag('config', 'G-XXXXXXX')
})
</script>
<template>
<!-- No markup needed; the composable owns injection -->
<span aria-hidden="true" />
</template>
Step 4 — For scripts outside the registry, drive useHead reactively
When a vendor has no @nuxt/scripts registry entry, inject through useHead with a computed that yields an empty array until consent. This keeps the tag out of the SSR head and adds it reactively on grant. The full mechanics of this path — and why an unguarded useHead leaks on the server — are covered in the companion guide on injecting consent-gated scripts with Nuxt useHead:
<script setup lang="ts">
import { computed } from 'vue'
const consent = useConsent()
useHead({
script: computed(() => {
// Guard both environment and consent: nothing renders on the server,
// nothing renders on the client until marketing consent is granted.
if (import.meta.server || !consent.value.marketing) return []
return [{
src: 'https://cdn.example-pixel.com/pixel.js', // marketing pixel vendor
async: true,
// Stable key lets Unhead dedupe and prevents double insertion on hot reload.
key: 'marketing-pixel',
}]
}),
})
</script>
Step 5 — Prefer Pinia when consent must survive route changes and feed actions
For larger apps, back the consent state with a Pinia store so revocation can trigger vendor teardown actions, not just re-render. The store action becomes the single mutation point the plugin calls:
// stores/consent.ts
import { defineStore } from 'pinia'
export const useConsentStore = defineStore('consent', {
state: () => ({ analytics: false, marketing: false }),
actions: {
grant(partial: Partial<{ analytics: boolean; marketing: boolean }>) {
Object.assign(this.$state, partial)
},
revokeAll() {
this.analytics = false
this.marketing = false
// Downstream watchers see the flip and tear vendors down without a reload.
},
},
})
Verification Checklist
Interaction with Consent, Worker Offloading, and the Deep-Dive Guide
The Nuxt gate is one layer in a larger consent architecture. It reads a decision it does not itself make:
- The upstream GDPR-compliant consent gating architecture defines when consent is considered granted (the
tcloadedcontract, purpose-to-vendor mapping). Nuxt’s plugin consumes that event; it must never treat acmpuishownpre-interaction callback as a grant. - When multiple vendors are gated, route every mutation through one canonical state as described in syncing consent states across multiple vendors. In Nuxt terms, that canonical state is your
useState('consent')value or Pinia store — do not let individual components subscribe to the CMP independently, or they will drift. - For the hardest injection case — a bespoke script that must be pushed into
useHeadreactively without an SSR leak — follow the step-by-step in the companion guide on injecting consent-gated scripts with NuxtuseHead. - If a gated vendor is CPU-heavy after it loads, combine this gate with offloading heavy scripts to Web Workers via Partytown so consent controls whether it runs and the worker controls where it runs.
Troubleshooting
Failure mode: vendor script appears in view-source before any consent
Symptom: curl of the page HTML shows the <script src="...gtag..."> tag in the <head> even though the banner has not been accepted.
Cause: useHead was called with a plain object literal (not a computed/ref), or the computed did not guard import.meta.server. Plain entries serialize into the SSR head.
Fix: Wrap the script array in a computed that returns [] when import.meta.server is true or consent is false. For registry scripts, pass the consent ref as the @nuxt/scripts trigger/consent option instead of injecting manually.
Failure mode: window.__tcfapi is not a function during SSR
Symptom: Server log throws ReferenceError: window is not defined or __tcfapi is not a function at render time.
Cause: CMP subscription code ran on the server. window and the CMP stub exist only in the browser.
Fix: Move the subscription into a plugins/consent.client.ts file (the .client suffix strips it from the server bundle), or guard it with if (import.meta.client).
Failure mode: script injects twice after hydration
Symptom: Two identical vendor requests in the Network panel, or dataLayer receives duplicate config calls.
Cause: The useHead script entry has no stable key, so Unhead cannot dedupe the server placeholder against the client entry; or useScript was called inside a component that renders more than once.
Fix: Give every injected script a stable key. For useScript, rely on the module’s built-in dedupe by using the registry composables, which key by src automatically.
Failure mode: consent granted but script never loads
Symptom: The banner reports acceptance, consent.value.analytics is true in Vue DevTools, but no vendor request fires.
Cause: The trigger passed to useScript/useHead is a non-reactive snapshot (a plain boolean captured once) rather than a ref/computed, so the watcher never re-evaluates.
Fix: Pass computed(() => consent.value.analytics) — a reactive source — not consent.value.analytics evaluated at setup time.
Frequently Asked Questions
Should I use @nuxt/scripts or raw useHead for consent gating?
Prefer @nuxt/scripts (useScript) whenever a registry entry or a plain src covers your vendor — it owns SSR-safety, deduplication, and the consent/trigger contract, so there is less to get wrong. Reach for a reactive useHead computed only for bespoke scripts that need custom attributes or inline body content the registry does not model. Both approaches share the same underlying Unhead engine; the module is simply a safer wrapper.
Why does my gated script still show up in the server-rendered HTML?
Because useHead is isomorphic: a plain (non-reactive) script entry is serialized into the SSR <head>. The fix is to make the entry reactive and guard the server environment — return an empty array when import.meta.server is true or consent is false. See the companion guide on injecting consent-gated scripts with Nuxt useHead for the full mechanism.
useState or Pinia for consent state in Nuxt?
Use useState for a lightweight boolean-map gate read by a handful of components — it is built into Nuxt, SSR-friendly, and needs no dependency. Move to Pinia when consent revocation must trigger imperative teardown (destroying SDK instances, aborting in-flight requests, expiring cookies) through store actions, or when many components and middleware read and mutate the state. Both are reactive and both work as useScript/useHead triggers.
Does this pattern work with nuxt generate (SSG) and static hosting?
Yes. Under nuxt generate, the same environment guard applies: import.meta.server is true during the build-time prerender, so the vendor script is excluded from the generated HTML. On the client, hydration runs the plugin and watcher exactly as in SSR mode, injecting only after consent. Static hosting changes nothing about the gate because the gate resolves entirely at client runtime.