Add a consent banner — or none — with plugin-consent
A consent banner asks visitors to agree before something on the site that
needs their consent runs: an analytics script, a YouTube video, a map. A site
that loads none of that has nothing to ask. @nera-static/plugin-consent
covers both cases: it shows a short info notice (or nothing) on a site without
such services, and a banner with a settings dialog on a site with them, and it
keeps every configured script and embed inert until the visitor agrees.
Every command, config and output below was run in a fresh nera new site with
plugin-consent 1.0.2 and @nera-static/validate 1.6.2.
1. Do you need a banner at all?
Start with what your site loads from other hosts. Every font, video, map or script from a third-party server sends the visitor's IP address there, and much of it sets cookies. The first option is not a banner but not loading it from elsewhere:
- serve fonts from your own site (this site's IBM Plex lives in
theme/assets/fonts/), - serve a video file from your own host with
<video>, or link to the video instead of embedding it, - run the statistics tool yourself, or do without one.
Self-hosting removes the third-party request. It does not by itself settle whether a tool that stores something on the visitor's device needs consent; that depends on what it stores and why. The background, in more depth: Do I need a cookie banner?
If nothing is left that needs consent, you need no banner. You can still
install the plugin: without a config/consent.yaml, or with one that lists no
categories, it is in notice mode and renders a short notice:
<section class="nera-consent nera-consent--notice" data-consent-notice="data-consent-notice" data-consent-revision="1" aria-labelledby="nera-consent-notice-text" hidden="hidden">
<p class="nera-consent__text" id="nera-consent-notice-text">This site uses only technically necessary cookies. <a class="nera-consent__link" href="/privacy.html">Privacy policy</a></p>
<div class="nera-consent__actions"><button class="nera-consent__button" type="button" data-consent-dismiss="data-consent-dismiss">Got it</button></div>
</section>
The runtime shows it until the visitor clicks "Got it". If you do not want
even that, set notice: false:
privacy_url: /privacy.html
notice: false
app.consent.mode is then 'none', and the views render nothing at all. Once
you add a category, notice: false no longer applies, and the build says so:
⚠️ plugin-consent: config/consent.yaml: ignoring notice: false, the site has categories and shows the banner
2. Install and wire the layout
npm install @nera-static/plugin-consent
npx nera-consent-config
npx nera-consent
npx nera-consent-config copies the documented config/consent.yaml, every
example commented out, so the site starts in notice mode. npx nera-consent
copies the five views (banner, settings, notice, head, embed). On a
site scaffolded with nera new, they land in
theme/views/vendor/plugin-consent/. Both commands skip a file or folder that
already exists; --force overwrites it, and your edits with it.
Plugins cannot touch your layout, so this is the one manual step. Add the
stylesheet, the head view and the runtime inside <head>, a settings link
in the footer, and the three other views at the end of <body>:
<!DOCTYPE html>
html(lang=app.lang)
head
meta(charset="UTF-8")
meta(name="viewport", content="width=device-width, initial-scale=1.0")
title #{ meta.title } | #{ app.name }
link(rel='stylesheet' href='/_nera/consent.css')
include /vendor/plugin-consent/head
script(src='/_nera/consent.js' defer)
body
a.skip-link(href="#main") #{ t('skip_to_content') }
block content
footer
a(href="/imprint.html") #{ t('imprint') }
= ' · '
a(href="/privacy.html") #{ t('privacy') }
= ' · '
a(href='/privacy.html' data-consent-open) Privacy settings
include /vendor/plugin-consent/banner
include /vendor/plugin-consent/settings
include /vendor/plugin-consent/notice
(The scaffold's other meta tags and its style block stay as they are; they
are left out here.)
consent.cssandconsent.jsare copied topublic/_nera/on every build, in every mode. The runtime needsdefer.banner,settingsandnoticeeach render only in their own mode, so include all three.- Any element with
data-consent-openopens the settings dialog, where the visitor can change or withdraw a choice at any time. On a link, thehrefis the fallback for a page without the dialog or without JavaScript.
The plugin needs @nera-static/core 4.13.0 or later, which every current
@nera-static/nera brings.
3. Gate a YouTube embed
Paste the embed code into a page as usual:
---
layout: pages/default.pug
title: Our video
description: A page with an embedded video
---
# Our video
<iframe src="https://www.youtube-nocookie.com/embed/abc123" title="Our video" width="560" height="315" allowfullscreen></iframe>
Then describe the service in config/consent.yaml, in one category:
revision: 1
privacy_url: /privacy.html
categories:
- id: media
services:
- name: YouTube
provider: Google Ireland Limited, Dublin
purpose: Shows embedded videos.
privacy_url: https://policies.google.com/privacy
hosts: [youtube.com, youtube-nocookie.com]
cookies:
- name: VISITOR_INFO1_LIVE
duration: 6 months
purpose: Estimates the visitor's bandwidth.
id: mediais one of four ids with a built-in label and description in English and German (analytics,media,marketing,functional); any other id needs alabel.name,providerandpurposeare required; a service without them is dropped with a warning.hostsare bare host names. An<iframe>whosesrchost equals one of them or is a subdomain of it is gated:www.youtube-nocookie.commatchesyoutube-nocookie.com.cookiesare listed in the settings dialog. The entry above is an example: list what the provider documents.
With a category configured, the site is in banner mode. After nera build,
the iframe in public/video.html has no src left; its URL moved to
data-consent-src, and a placeholder follows it:
<div class="nera-consent-embed" data-consent-embed data-consent="media"><iframe data-consent="media" data-consent-src="https://www.youtube-nocookie.com/embed/abc123" title="Our video" width="560" height="315" allowfullscreen hidden></iframe>
<div class="nera-consent-embed__placeholder">
<p class="nera-consent-embed__text">This content is provided by YouTube. Loading it connects to <strong>Google Ireland Limited, Dublin</strong>.</p><button class="nera-consent-embed__load" type="button" data-consent-load>Load YouTube</button><a class="nera-consent-embed__privacy" href="https://policies.google.com/privacy" rel="noopener">Privacy policy of the provider</a><button class="nera-consent-embed__settings" type="button" data-consent-open>Privacy settings</button>
</div>
</div>
In a browser, the test page made no request to another host until a click. "Load YouTube" loaded this one embed and stored nothing; "Accept all" in the banner stored the choice and loaded every gated element. The banner itself puts Accept all, Reject all and Settings on the first layer, in that fixed order and one shared style:
<section class="nera-consent" data-consent-banner="data-consent-banner" data-consent-revision="1" data-consent-categories="media" aria-labelledby="nera-consent-title" hidden="hidden">
<h2 class="nera-consent__title" id="nera-consent-title">Cookies and external content</h2>
<p class="nera-consent__text">This site can load content and services from third parties. They only load if you agree. You can change your choice at any time in the settings. <a class="nera-consent__link" href="/privacy.html">Privacy policy</a></p>
<div class="nera-consent__actions"><button class="nera-consent__button" type="button" data-consent-accept-all="data-consent-accept-all">Accept all</button><button class="nera-consent__button" type="button" data-consent-reject-all="data-consent-reject-all">Reject all</button><button class="nera-consent__button" type="button" data-consent-open="data-consent-open">Settings</button></div>
</section>
<embed>, <object>, <video> and <audio> from a configured host are
gated the same way. Two places the build does not reach:
-
An iframe written directly into a Pug view is not rewritten. Use the mixin from the published
embedview there; it renders the same placeholder:include /vendor/plugin-consent/embed +consent-embed({ category: 'media', service: 'YouTube', src: 'https://www.youtube-nocookie.com/embed/abc123', attributes: { title: 'Our video', width: 560, height: 315, allowfullscreen: true }, })categoryandservicemust name a configured service, or the build fails rather than render a live iframe. -
A gated
<video>,<audio>or<embed>written inline in a Markdown paragraph ends up inside a<p>, which the browser splits around the placeholder. Put its opening tag alone on a line, after a blank line.
4. Gate an analytics script
A script you configure under a service's scripts is emitted by the head
view, inert. Add a second category:
revision: 2
privacy_url: /privacy.html
categories:
- id: media
# … YouTube as above
- id: analytics
services:
- name: Matomo
provider: Example GmbH, Berlin
purpose: Counts visits without profiles.
cookies:
- name: _pk_id
duration: 13 months
purpose: Recognises returning visitors.
scripts:
- inline: |
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
- src: https://matomo.example.com/matomo.js
async: true
Each script entry has exactly one of src and inline. In the built <head>,
both are type="text/plain", which the browser does not run, and the URL sits
in data-src:
<link rel="stylesheet" href="/_nera/consent.css" />
<script type="text/plain" data-consent="analytics">var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
</script>
<script type="text/plain" data-consent="analytics" data-src="https://matomo.example.com/matomo.js" async="async"></script>
<script src="/_nera/consent.js" defer="defer"></script>
Once the visitor grants analytics, the runtime activates them in document
order. In the test site, matomo.example.com/matomo.js was requested only
after "Accept all".
You can write the same form by hand, in a view or right in a page's Markdown; the runtime treats it alike:
<script type="text/plain" data-consent="analytics" data-src="https://stats.example.org/counter.js" defer></script>
data-consent names the category, data-src holds the URL, and every other
attribute (async, defer, integrity, …) is copied to the activated script.
Note revision: 2. A visitor's stored choice carries the revision it was
made under; when the built revision is higher, the banner shows again. Adding
a service or a category to a site that already shows the banner does not ask
returning visitors again by itself, so bump revision whenever you do. In the
test site, a choice stored under revision 2 brought the banner back as soon as
the site was rebuilt with revision: 3.
Withdrawal in the settings dialog is stored at once but takes effect from the next page load: a script that already ran cannot be unloaded.
5. List your services on the privacy page
Your privacy page should name the services you use. The plugin puts them into
app.consent, per language under app.consent.locales, with the texts already
resolved. Give the privacy page its own view:
extends ../layouts/layout
block content
main#main
!= content
- const consentLocale = app.consent.locales[meta.lang || app.lang] || app.consent.locales.en
each category in consentLocale.categories
h2= category.label
each service in category.services
h3= service.name
p #{service.provider}: #{service.purpose}
if service.privacyUrl
p
a(href=service.privacyUrl rel='noopener') #{consentLocale.texts.service_privacy}
if service.cookies.length
table
thead
tr
th(scope='col')= consentLocale.texts.cookie_name
th(scope='col')= consentLocale.texts.cookie_duration
th(scope='col')= consentLocale.texts.cookie_purpose
tbody
each cookie in service.cookies
tr
td= cookie.name
td= cookie.duration
td= cookie.purpose
and point pages/privacy.md at it with layout: pages/privacy.pug. The table
then comes from the same config as the banner and cannot drift from it:
<h2>External media</h2>
<h3>YouTube</h3>
<p>Google Ireland Limited, Dublin: Shows embedded videos.</p>
<p><a href="https://policies.google.com/privacy" rel="noopener">Privacy policy of the provider</a></p>
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Duration</th>
<th scope="col">Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td>VISITOR_INFO1_LIVE</td>
<td>6 months</td>
<td>Estimates the visitor's bandwidth.</td>
</tr>
</tbody>
</table>
<h2>Statistics</h2>
…
The rest of the privacy policy, who you are, what your host logs, the visitor's rights, is still yours to write.
6. Theme and translate
Every color and size of banner, dialog and placeholder is a --nera-consent-*
custom property, so your own stylesheet can set them anywhere:
:root {
--nera-consent-font: system-ui, sans-serif;
--nera-consent-bg: #fffbeb;
--nera-consent-fg: #1c1917;
--nera-consent-button-bg: #92400e;
--nera-consent-button-fg: #ffffff;
--nera-consent-radius: 0;
}
@media (prefers-color-scheme: dark) {
:root {
--nera-consent-bg: #1c1917;
--nera-consent-fg: #fafaf9;
--nera-consent-button-bg: #fbbf24;
--nera-consent-button-fg: #1c1917;
}
}
Link it in the layout (link(rel='stylesheet' href='/css/site.css')). The
plugin's README has the full table of properties. Accept all, Reject all and
Settings share one set of button properties on purpose: there is no way to
make one of them stand out. The defaults reach a contrast of at least 4.5:1;
keep that when you change them.
The texts come in English and German. Every text resolves per page language
(meta.lang || app.lang), first hit wins: texts.<lang> in
config/consent.yaml, then consent.<key> in your app.translations, then
the built-in text. A Spanish page, for example:
texts:
es:
banner_title: Cookies y contenido externo
accept_all: Aceptar todo
reject_all: Rechazar todo
settings: Ajustes
or, next to your other translations:
translations:
es:
consent.placeholder_load: Cargar {service}
consent.banner_text: Este sitio puede cargar contenido y servicios de terceros. Solo se cargan si das tu consentimiento.
A key you do not translate falls back to English: on the Spanish test page,
the banner's privacy link still read "Privacy policy". The README lists every
key. A category's label and description, and a service's purpose, take
one text per language the same way:
- id: analytics
label:
en: Statistics
es: Estadísticas
7. What nera build --check reports
nera build --check checks the built public/ for privacy problems, among
others. The output below is from @nera-static/validate 1.6.2, the version
@nera-static/nera 1.17.0 brings. Later releases may report gated markup
differently.
Before gating, with the plain iframe in pages/video.md, no categories,
and the analytics script as a plain tag in the layout's head:
script(src='https://matomo.example.com/matomo.js' async)
privacy-third-party reports both:
public/imprint.html
:24 warning loads matomo.example.com — a third-party request that sends the visitor's IP address; self-host the resource, gate the element with `data-consent` (the markup of @nera-static/plugin-consent) so it loads only after the visitor agrees, or name the host in the privacy policy and list the host under `privacy.allowed_hosts` in config/validate.yaml once it is accounted for (Art. 6 DSGVO) (on 4 pages, e.g. public/imprint.html, public/index.html, public/privacy.html, …) privacy-third-party
public/video.html
:30 warning loads a YouTube embed from www.youtube-nocookie.com — a third-party request that sends the visitor's IP address; consider a click-to-load placeholder (Art. 6 DSGVO, § 25 TDDDG), e.g. gate the element with `data-consent` (the markup of @nera-static/plugin-consent), or list the host under `privacy.allowed_hosts` in config/validate.yaml once it is accounted for
0 error(s), 2 warning(s)
The hint "gate the element with data-consent" appears when a host's first
use in a file is an element the plugin can gate: a script, iframe, embed,
object, video or audio. Images, <link>s and CSS keep the plain message.
After gating, with the config from steps 3 and 4 and the hand-written tag
in pages/stats.md:
✓ No problems found.
Validate does not read a <script> whose type is not JavaScript as a
resource, and an iframe or media element with data-consent and only
data-consent-* loading attributes loads nothing, so the gated markup gives
no privacy-third-party finding.
A tag that is marked but still loads is what privacy-consent-leak
reports. Leave out type="text/plain" and keep src in the hand-written tag:
<script data-consent="analytics" src="https://stats.example.org/counter.js" defer></script>
and the check finds it twice:
public/stats.html
:33 warning loads stats.example.org — a third-party request that sends the visitor's IP address; self-host the resource, gate the element with `data-consent` (the markup of @nera-static/plugin-consent) so it loads only after the visitor agrees, or name the host in the privacy policy and list the host under `privacy.allowed_hosts` in config/validate.yaml once it is accounted for (Art. 6 DSGVO) privacy-third-party
:33 warning `<script data-consent="analytics" src="https://stats.example.org/counter.js">` is marked `data-consent` but runs as JavaScript — the browser runs it before the visitor agrees; set `type="text/plain"`, rename `src` to `data-src`, as in the markup of @nera-static/plugin-consent (§ 25 TDDDG) privacy-consent-leak
0 error(s), 2 warning(s)
Both are warnings, so the build still succeeds. To fail it, promote the rule
in config/validate.yaml:
rules:
privacy-consent-leak: error
privacy-consent-leak: off switches it off. The check sees only the built
HTML: it cannot tell whether your privacy policy names a service, or whether a
service needs consent in the first place.
Defaults, not legal advice
The plugin's texts and behaviour are defaults, not legal advice, and nothing here makes a site compliant with any law. What the plugin does is technical, and it holds whatever the config says:
- The banner and dialog name each configured service, its provider and purpose, so the consent can be informed and specific (Art. 4 Nr. 11, Art. 7 DSGVO).
- "Reject all" is on the first layer, as visible and as easy to use as "Accept all".
- Nothing is pre-selected; "necessary" is implicit and cannot be configured.
- Nothing gated runs or loads before consent (§ 25 (1) TDDDG).
- Consent can be withdrawn at any time through the settings link, without a rebuild.
- A
revisionbump asks every visitor again. - Storing the visitor's choice (in
localStorage, undernera-consent) is strictly necessary and needs no consent (§ 25 (2) Nr. 2 TDDDG).
Whether your site needs consent, which services you list and what your banner and privacy policy say are your decisions. Check them yourself, or with someone qualified to advise you. The plugin's README and npm page have the full config reference, the markup contract and the known limitations.
Next
How this website is built shows the plugins of this site working together. nera.js.org itself loads nothing from another host, so it shows no banner.