Blog

A cookie banner for a static site: Hugo, Astro, Eleventy, Jekyll and plain HTML

Static sites need a cookie banner when they load analytics, fonts or embeds. One script tag in the layout, marked tags for the trackers, no build step. Snippets for Hugo, Astro, Eleventy, Jekyll and plain HTML.
Published 20 October 2026 · By the Cookiequiet team · Markdown

A static site has no server-side code, but it has a browser on the other end, and the browser is where cookies live. If your Hugo blog loads Google Analytics, your Astro landing page embeds a YouTube video, or your Jekyll documentation pulls fonts from Google, the consent rules apply exactly as they do to a WordPress site. The good news is that a static site is the easiest case to make compliant: one script tag in the shared layout, a change of attribute on each tracker, and no plugin to keep updated. Here is the pattern once, then the place to put it in each generator.

The pattern, in plain HTML

Three pieces, all in the <head>:

html
<script src="https://cookiequiet.com/cq.js" data-site="YOUR_SITE_KEY"
data-consent-mode="1" data-auto-block="1" defer></script>
​
<script type="text/plain" data-cq-category="analytics" async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script type="text/plain" data-cq-category="analytics">
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
</script>

And in the body, embeds held until consent:

html
<iframe data-cq-src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
data-cq-category="marketing" width="560" height="315" title="Demo"></iframe>

The script sets Google Consent Mode defaults to denied before any Google tag runs, holds dynamically created scripts and iframes of known trackers, fetches the site's configuration in the visitor's language, shows the banner where the law requires one, activates the marked tags when their category is granted, and records each choice. A footer link with data-cq-show-preferences reopens the preferences. The reasoning behind each attribute is in the script blocking guide; the rest of this article is about where the markup goes.

Hugo

Hugo builds every page from a layout, and the head is almost always a partial. In current Hugo versions partials live in layouts/_partials/ (older projects and many themes use layouts/partials/); a theme's partial is overridden by a file with the same name in your project's layouts directory. Create or edit the head partial and add the snippet:

html
{{/* layouts/_partials/head.html */}}
<meta charset="utf-8">
<title>{{ .Title }}</title>
<script src="https://cookiequiet.com/cq.js" data-site="YOUR_SITE_KEY"
data-consent-mode="1" data-auto-block="1" defer></script>
{{ if hugo.IsProduction }}
<script type="text/plain" data-cq-category="analytics" async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
{{ end }}

Call it from the base layout with {{ partial "head.html" . }} if it is not already. If your theme ships its own Google Analytics template, disable it in the configuration and add the marked tag yourself; a theme-injected tag above the consent script runs before it. The Hugo platform guide has the theme-specific notes.

Astro

Astro processes <script> tags in components by default: it bundles and hoists them. Its documentation says it does not process a script tag that has any attribute other than src, and recommends the is:inline directive for scripts loaded from outside your project. Both apply here; add is:inline so the intent is explicit:

html
---
// src/layouts/Layout.astro
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{title}</title>
<script is:inline src="https://cookiequiet.com/cq.js" data-site="YOUR_SITE_KEY"
data-consent-mode="1" data-auto-block="1" defer></script>
<script is:inline type="text/plain" data-cq-category="analytics" async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
</head>
<body><slot /></body>
</html>

Astro leaves type="text/plain" scripts alone, so the inline gtag configuration block can be marked the same way. For Astro islands that load third-party widgets at runtime, the auto-blocker holds known trackers; mark the rest. See the Astro platform guide.

Eleventy

Eleventy layouts live in _includes/ and pages opt in with layout: base.njk in their front matter; the layout prints the page with {{ content | safe }}. Put the snippet in the layout's head:

html
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<script src="https://cookiequiet.com/cq.js" data-site="YOUR_SITE_KEY"
data-consent-mode="1" data-auto-block="1" defer></script>
<script type="text/plain" data-cq-category="analytics" async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
</head>
<body>{{ content | safe }}</body>
</html>

The file is _includes/base.njk. Nunjucks and Liquid leave the data- attributes untouched. If you generate the analytics tag from an environment variable, keep the type="text/plain" on the generated output.

Jekyll

Jekyll looks for include files in _includes/ and inserts them with {% include head.html %}. Most themes, including Minima, build the head from such an include; copy the theme's file into your own _includes/head.html to override it, then add the snippet:

html
<head>
<meta charset="utf-8">
<title>{{ page.title | default: site.title }}</title>
<script src="https://cookiequiet.com/cq.js" data-site="YOUR_SITE_KEY"
data-consent-mode="1" data-auto-block="1" defer></script>
{% if jekyll.environment == "production" %}
<script type="text/plain" data-cq-category="analytics" async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
{% endif %}
</head>

Themes that read a google_analytics key from _config.yml inject their own unmarked tag; remove the key and add the marked tag yourself.

Tags your generator adds for you

The trackers you did not write are the ones that escape marking. Hugo ships internal templates for Google Analytics that themes call with a single line; Jekyll themes read an analytics id from the configuration; Astro integrations and Eleventy plugins add scripts to the head from inside node_modules. Each of these outputs a plain <script> tag that runs before consent. The fix is the same every time: switch the feature off in the configuration and write the marked tag yourself in the layout, where you can see it. After each theme or plugin update, open the built HTML of one page and search for <script to confirm nothing unmarked came back.

Keep the consent snippet and the marked tags in one include or component (a consent.html partial in Hugo, a Consent.astro component, a Nunjucks include, a Jekyll include) so that a change to the site key or a new tracker is a one-file edit.

Testing before you deploy

The banner is configured by site key, not by domain, so the snippet works on localhost during development exactly as in production: run the dev server, open a private window, and check that nothing fires before a click, that Reject all keeps it that way, and that Accept all activates the marked tags. Geo-targeting uses the visitor's IP address, so on a local machine in a consent country you see the consent banner; to test the opt-out notice for US states, set geo-targeting to "everywhere" temporarily or test from a US connection. Consent records made from localhost land in the same log as production ones, so use a separate site key for development if you want a clean chart.

Things specific to static hosting

  • No server, same geo-targeting. The banner asks Cookiequiet's endpoint for the visitor's country, so showing the banner only in regulated countries works on GitHub Pages, Netlify, Vercel or an S3 bucket alike.
  • The policy pages are static too. Generate the cookie and privacy policies from the scan, copy them as Markdown or HTML into your content directory, and rebuild. Or link to the hosted versions.
  • Fonts. Static site themes love Google Fonts. Self-host them; the Google Fonts guide explains why and how.
  • Build-time analytics is fine. Server logs and cookieless, publisher-only measurement do not need the banner; see analytics without consent.
  • Verify after each deploy. Open the site in a private window: no analytics or embed request before a click, nothing new after Reject all, everything after Accept all. A scan with the cookie scanner confirms the production pages.

Questions

No. A site that loads no analytics, no embeds, no ad tags and no third-party widgets has nothing to ask consent for. Check the Network tab in a private window to be sure; themes often add fonts or analytics you forgot about.
The script is about 7 KB compressed, loads with defer, and renders in a shadow root, so it does not block rendering or fight your CSS. Marked trackers load later, after consent, which makes the first paint faster, not slower.
No. The script is served from cookiequiet.com so that configuration changes made in the dashboard apply without a rebuild. Your trackers and embeds, on the other hand, stay in your repository as marked tags.
Yes. Nothing runs on your server; the script, the configuration, geo-targeting and the consent log are handled by Cookiequiet's endpoints. Any static host that serves your HTML works.

Put a quiet banner on your website today

Free for one website with every core feature, no card and no trial clock. Upgrade a website to Starter for $3 a month to match your brand with AI and drop the branding, or to Pro for $9 to design it with the AI agent.