A gated vendor tag appears in the server-rendered HTML, the client-side gate correctly removes it after hydration, and the browser has already fetched the script by then.
Triage: Read the Response, Not the DOM
The inspector shows the DOM after hydration, so a tag correctly removed by client code has already vanished by the time you look. Only the raw response reveals the leak.
curl -s https://your-site.example/ | grep -c 'vendor.example'— a non-zero count is the finding.- Repeat for an authenticated route and for a route rendered by a different layout.
- In the browser, load with the banner unanswered and filter the Network panel to the vendor origin. A request confirms the markup was acted on.
- Check where the tag is declared:
useHead, a<script setup>block, or a component template.
Root Cause: The Server Has No Consent State to Read
Nuxt evaluates components on the server and again in the browser. On the server there is no localStorage, no consent platform, and no window — so any check reading those returns a falsy or undefined value, and a guard written as “render unless explicitly denied” renders.
Even a correct guard is insufficient if the component’s template contains the tag, because the template is what the server serialises. The client-side correction happens after the response has been parsed and after the preload scanner has discovered the URL, which is why the gate must remove the element rather than hide it.
A second, subtler hazard is module scope. A module is evaluated once per server process rather than once per request, so a top-level variable holding consent state is shared between concurrent visitors — a correctness bug and a data-protection incident that is invisible in development, where requests are handled one at a time.
Resolution: Client-Only by Construction, Cookie for the Rest
// plugins/consent.client.js — the .client suffix removes it from the server
// bundle entirely, so there is no guard to forget.
export default defineNuxtPlugin(() => {
const consent = useState('consent', () => ({ analytics: 'pending' }));
window.__cmp?.addEventListener?.('change', (s) => {
consent.value = { analytics: s.statistics ? 'granted' : 'denied' };
});
watch(
() => consent.value.analytics,
(state) => {
if (state !== 'granted') return;
useHead({ script: [{ src: VENDOR_SRC, async: true, crossorigin: 'anonymous' }] });
},
{ immediate: true },
);
});
// server/middleware/consent.js — a hint for returning visitors, read per request.
export default defineEventHandler((event) => {
const raw = getCookie(event, 'consent');
// Attach to the event context, never to module scope.
event.context.consent = raw ? safeParse(raw) : null;
});
The .client suffix is doing the important work. It removes the module from the server build rather than guarding it at runtime, which means no future edit can reintroduce a server-side read — a stronger guarantee than import.meta.client, and one that survives a refactor by someone who has not read this page.
useState rather than a module-level ref is the other half. Nuxt scopes useState per request on the server, so concurrent visitors cannot observe one another, which is precisely the property a top-level variable lacks.
Verification: The Grep Returns Zero
Rebuild, deploy to a preview, and repeat the curl | grep across several routes. Every count should be zero. Then load with consent pending and confirm no vendor request; accept and confirm one appears promptly.
Keeping the Property After the Fix
The correction is a one-off; keeping it is not. A consent leak of this kind is invisible in every ordinary check — the site builds, renders, and behaves correctly — so the only thing that prevents its return is an assertion that runs without anyone remembering to look.
Add one integration test that renders a route through the server pipeline, takes the resulting HTML string, and asserts that no vendor hostname appears in it. It runs in milliseconds, needs no browser, and fails the moment someone reintroduces a tag into markup for a reason that seemed sensible at the time.
Generate the hostname list from the vendor registry rather than hard-coding it, so a vendor added next quarter is covered automatically. That closes the loop the same way it does everywhere else in this area: one source of truth, several consumers, and no opportunity for the list the test checks to drift from the list the site loads.
One Composable, Every Surface
Nuxt offers several places a vendor tag can be declared — a layout, a page, a plugin, route middleware, a server route — and each of them is a place a gate can be written slightly differently. The failure that follows is not a single leak but a slow divergence: two surfaces reading the platform independently, agreeing most of the time, and disagreeing during the hydration window when it matters.
Concentrate the reading in one composable and let every surface consume it. useConsent() in composables/ is auto-imported everywhere, holds one reactive value per request, and gives revocation a single place to live — which matters because revocation is the case where scattered gates fail most visibly. A gate that stops rendering a tag does nothing about a vendor already running, so the teardown call has to be somewhere central rather than repeated per surface.
The practical test is a grep. Searching for the consent platform’s global should return matches in exactly one file. Anything else is a second reader, and a second reader is a second opinion about a state that should have exactly one.
Common Pitfalls
- Using
<svelte:head>-style head blocks for gated tags. In Nuxt the equivalent trap is auseHeadcall outside a client-only context: it participates in the server render. - Guarding with
import.meta.clientinstead of a.clientfile. The guard works and is one edit away from not working. - Holding consent in a module-level
ref. Shared across requests on the server. - Auditing in the Elements panel. It shows the hydrated DOM, in which the leak has already been cleaned up.
Frequently Asked Questions
Should the server read the consent cookie at all?
It is worth doing for returning visitors. Reading a small consent cookie in server middleware lets the initial HTML reflect the stored decision — no banner for someone who already accepted, and no layout shift when gated content appears — which is a materially better experience than correcting it after hydration.
Treat it as a hint rather than authority. The client-side platform remains the source of truth because it is the only thing that can observe a change made in another tab, so the server renders the likely state and the client corrects it if it has since changed.
Does ssr: false on a route solve this?
It removes this particular leak, because there is no server-rendered HTML to leak into — and it replaces it with a worse trade. Nothing renders until JavaScript runs, so every visitor waits longer for content that has nothing to do with the vendor you were trying to gate.
Disabling server rendering is a decision about a route’s own rendering strategy, made for its own reasons. The client-only plugin described here works identically under either setting, which is the argument for fixing the gate rather than removing the rendering.
How do we test the concurrency hazard?
Send concurrent requests with different consent cookies and assert that each response reflects its own. A handful of parallel requests against a preview deployment is enough — the failure, when present, is immediate and obvious, because responses carry the wrong state rather than being subtly off.
Add it as a test rather than a one-off. Module-scope state is easy to reintroduce, it is invisible in development, and this is the only check that catches it before a visitor does.
Can the server decide anything useful about consent at all?
It can decide two things, and both are worth taking. From a consent cookie it can render the correct state for a returning visitor, which removes the banner flash and the layout shift that follow a client-side correction. And from the request’s region it can choose which defaults apply, which is the regional routing decision and genuinely belongs before the response.
What it cannot decide is a first-time visitor’s consent, because there is none to read. That case has to render the ungated state — no vendor markup at all — and let the client take over once a decision exists. Treating the two cases separately is what lets you optimise the returning visit without compromising the first one.