An analytics snippet sits in your Astro layout, appears in every built HTML file, and executes before anything else on the page — including on the pages where the visitor has not yet made a consent decision.
Triage: Confirm It Is in the Built Output
Astro builds to static HTML by default, so a snippet in a layout is baked into every generated file — which is a stronger form of the problem than a server-rendered framework has, because the output is cached and served identically to everyone.
- Build the site and grep the output directory for the vendor’s hostname:
grep -rl 'vendor.example' dist/. - Count the matches. Every file listed contains the snippet in its markup.
- Open one and confirm the snippet is inline in the head rather than referenced from a component.
- Check whether any consent check exists around it — in a statically built file, a runtime check cannot prevent the markup being present.
Root Cause: Layout Markup Is Build Output
Anything written in an Astro component’s template is rendered at build time and emitted into the HTML. That is the framework working as designed — it is what makes Astro fast — and it means a snippet in a layout is not “code that runs on every page” so much as “text that appears in every file”.
The consequence for a third-party tag is that no runtime gate can help. A consent check placed alongside the snippet runs after the browser has already parsed the markup, discovered the vendor URL through the preload scanner, and quite possibly fetched it. Removing the element afterwards changes the DOM and not the network.
An island changes the category of the thing. A component marked client:only is never rendered during the build at all — the framework emits a placeholder and the component’s code runs exclusively in the browser — so the vendor URL never appears in any built file, and the injection becomes something your code decides rather than something the markup declares.
Resolution: A Client-Only Island That Owns the Injection
// src/components/Analytics.jsx — never rendered at build time.
import { useEffect } from 'react';
import { VENDORS } from '../lib/vendors.js';
export default function Analytics() {
useEffect(() => {
// Runs only in the browser, only after the consent decision resolves.
const stop = consent.subscribe((state) => {
if (state.analytics !== 'granted') return;
const s = document.createElement('script');
s.src = VENDORS.analytics.src;
s.async = true;
s.fetchPriority = 'low'; // set before append, or it has no effect
s.crossOrigin = 'anonymous';
document.head.appendChild(s);
});
return stop;
}, []);
return null; // renders nothing, ever
}
---
// src/layouts/Base.astro
import Analytics from '../components/Analytics.jsx';
---
<html lang="en">
<head>
<!-- Free and safe: no payload, no consent question. -->
<link rel="preconnect" href="https://cdn.vendor.example" crossorigin>
</head>
<body>
<slot />
<!-- client:only skips the build render entirely. -->
<Analytics client:only="react" />
</body>
</html>
The preconnect is worth keeping in the markup even though the script is not. It transfers nothing, raises no consent question, and completes the handshake during the period the visitor is reading the banner — so the post-consent injection finds a warm socket. That is the same trade the facade pattern makes, applied to a different waiting period.
Verification: Grep the Build Again
Rebuild and repeat the grep. The vendor hostname should appear in zero built HTML files. Then load a page and confirm no request to the vendor origin occurs before the consent decision, and that one occurs promptly after.
Why Not Just Use client:load?
A component with client:load is rendered during the build and hydrated as soon as the page loads, which for an analytics component that renders nothing seems equivalent. It is not, in one decisive respect: the build render happens, so anything the component emits — including a script tag written in its template — appears in the HTML.
The distinction matters less if your component returns null and does all its work in an effect, which is why the two arrangements often behave the same in practice. But client:only states the intent in the markup rather than relying on the implementation staying that way, and it removes an entire failure mode: someone later adding a <script> to the component’s template cannot leak it into the build, because there is no build render to leak into.
It also avoids a subtler cost. A client:load component is server-rendered and then hydrated, which means the framework ships both the rendered output and the hydration code; client:only ships only the code. For a component that renders nothing, the server output is pure overhead — small, but paid on every page of a site whose whole premise is shipping less.
The Build Grep Is Worth Keeping
The check that found the problem is also the check that keeps it fixed. A single grep over the built output for every vendor hostname takes a second in a build pipeline and asserts a property nothing else does: that no third-party URL is present in any generated file.
Wire it in as a build step rather than a habit. It catches the case where someone adds a snippet to a layout for a quick experiment and forgets to remove it, and the case where a component that used to be client-only loses the directive during a refactor — both of which are silent in every other check, because the site builds, renders and behaves correctly.
Generate the hostname list from the vendor registry so the assertion covers new vendors automatically. A grep with a hard-coded list drifts in exactly the direction that matters: the vendor added last week, by someone who did not know the check existed, is the one it will not catch.
Common Pitfalls
-
Putting the vendor URL in the component’s template. With
client:onlythere is no build render to leak it, but the habit will bite in a component that is not client-only. -
Assuming a hydration directive removes the server output. Only
client:onlydoes; the others render at build time and hydrate later. -
Forgetting the framework argument.
client:onlyrequires the renderer name —client:only="react"— because the framework cannot infer it without a server render. -
Preloading the vendor script. The
preconnectis safe; apreloadtransfers the payload before any decision and will be evicted unused. -
Leaving the old snippet in place. A layout snippet and an island loading the same vendor produce two copies, and the island’s careful gating is irrelevant because the markup already fired.
-
Adding the framework integration solely for this. If the site ships no other client framework, a plain module script achieves the same result without the dependency.
Frequently Asked Questions
Does a client-only island hurt Core Web Vitals?
For a component that renders nothing, no — there is no content to be missing, so nothing shifts and nothing paints later. The placeholder Astro emits occupies no space, and the JavaScript chunk is fetched alongside the page’s other assets.
The consideration is different for a component that renders visible content. There, client:only means the content does not exist until JavaScript runs, which delays paint and risks a layout shift when it appears — so reserve the box, exactly as for any deferred content. An analytics component sidesteps this entirely by rendering nothing.
Can several vendors share one island?
Yes, and they should. One island subscribing to the consent state and iterating a vendor registry is easier to reason about than five islands each with their own subscription, and it gives revocation a single place to live.
It also means the vendor list is data rather than markup, so adding a vendor is a change to one file rather than a change to a layout — which matters for the same reason it matters everywhere else in this area: the registry can then drive the consent categories, the policy allowlist and the inventory from the same source.
What about analytics that must run on a static page with no framework?
An island requires a UI framework integration, which is unnecessary weight if the site otherwise ships none. For that case, a plain script file loaded with defer and containing the same consent-subscription logic does the identical job — the mechanism that matters is that the vendor URL lives in JavaScript rather than in markup, not that a framework is involved.
Astro’s <script> tag in a component achieves this: it is processed as a module, emitted as a separate file, and never inlined into the HTML by default. That is the lighter option for a site with no other client framework, and it keeps the vendor URL out of the built output just as effectively.