Near zero-configuration GOV.UK cookie consent banner, Google Tag Manager loading and Google Consent Mode integration for Node.js services. Built for Defra Hapi services, but framework-agnostic with a first-class Express adapter.
Source: https://github.com/TransformCore/govuk-analytics-consent
- GOV.UK Frontend compatible cookie banner
- Consent stored in a
cookies_policycookie - Consent Mode defaults to denied before GTM loads
- Consent Mode updated the moment a user accepts or rejects
- GTM loaded automatically from
GTM_CONTAINER_ID
npm install --save @transform-uk/govuk-analytics-consentSet the container ID:
GTM_CONTAINER_ID=GTM-XXXXXXXimport { registerGovUkAnalyticsConsent, govukAnalyticsConsentTemplatePath } from '@transform-uk/govuk-analytics-consent'
// Add the package templates to your Nunjucks search paths.
const searchPaths = [govukAnalyticsConsentTemplatePath(), 'node_modules/govuk-frontend/dist', 'src/views']
registerGovUkAnalyticsConsent(server, { serviceName: 'Apply for a licence' })import { registerGovUkAnalyticsConsent, govukAnalyticsConsentTemplatePath } from '@transform-uk/govuk-analytics-consent'
nunjucks.configure([govukAnalyticsConsentTemplatePath(), 'node_modules/govuk-frontend/dist', 'views'], {
express: app
})
registerGovUkAnalyticsConsent(app, { serviceName: 'Apply for a licence' }){% from "govuk-analytics-consent/macro.njk" import
govukAnalyticsConsentHead, govukAnalyticsConsentNoscript,
govukAnalyticsConsentBanner, govukAnalyticsConsentScripts %}
<head>
{{ govukAnalyticsConsentHead(govukAnalyticsConsent) }}
</head>
<body>
{{ govukAnalyticsConsentNoscript(govukAnalyticsConsent) }}
{{ govukAnalyticsConsentBanner(govukAnalyticsConsent) }}
...
{{ govukAnalyticsConsentScripts(govukAnalyticsConsent) }}
</body>govukAnalyticsConsent is injected into the view context by registerGovUkAnalyticsConsent
(Hapi view responses, Express res.locals). Runnable examples are in examples.
Not using Nunjucks? The same HTML is available directly:
const { head, noscript, banner, scripts } = res.locals.govukAnalyticsConsentAll optional.
| Option | Default | Notes |
|---|---|---|
gtmContainerId |
process.env.GTM_CONTAINER_ID |
Must match GTM-XXXXXXX; omitted means the service runs un-instrumented |
cookieName |
cookies_policy |
|
cookieVersion |
1 |
Bumping it re-prompts every user |
routePrefix |
/govuk-analytics-consent |
|
cookiesPageUrl |
none | Renders the banner's "View cookies" link; same-origin paths only |
consentWaitForUpdate |
500 |
Consent Mode wait_for_update in ms; false omits it |
serviceName |
this service |
Used in the banner heading |
categories |
essential + analytics | CookieCategory[] metadata |
secureCookie |
NODE_ENV === 'production' |
|
cookieMaxAge |
1 year (seconds) | |
getNonce |
none | (request) => string — applied to every injected <script> for CSP |
- The head snippet initialises
dataLayer, callsgtag('consent', 'default', …)with every signal denied pluswait_for_update, then loads the GTM container. - The deferred client script reads
cookies_policy. If a choice exists it immediately callsgtag('consent', 'update', { analytics_storage: … }), and also pushes acookie_consent_updatedataLayer event so GTM tags can trigger on it. - If no choice exists the banner is shown. Accept/reject writes the cookie, updates Consent Mode and swaps in the confirmation message.
- Without JavaScript the banner form posts to
{routePrefix}/consent, which sets the cookie and redirects back. Return paths are validated as same-origin, so it cannot be used as an open redirect.
The cookie is deliberately not httpOnly — the browser must read it to decide whether to
show the banner without a server round-trip.
interface ConsentState {
version: number
analytics: boolean | null // null = no choice made
updatedAt: string
}Any malformed, tampered or out-of-date cookie degrades to "no choice made" and re-prompts.
| Route | Purpose |
|---|---|
GET {routePrefix}/consent.js |
Browser consent manager (ETag, 1 hour cache) |
POST {routePrefix}/consent |
No-JavaScript fallback |
CookieCategory[] metadata already drives the banner and is exposed on the view context, so a
preferences page can be added without a breaking change: build the page from
govukAnalyticsConsent.categories, post to the existing consent route, and set
cookiesPageUrl to make the banner link to it.
npm install
npm test # builds the browser bundle, then runs Vitest
npm run buildThe package is published as a public scoped npm package. The prepack script builds dist,
and prepublishOnly runs type checking and tests before publishing.
npm publish --access publicTests cover the pure consent/cookie logic, Consent Mode snippet generation and injection safety, the Nunjucks macros against the shared HTML builders, the browser manager under jsdom, and both integrations end to end.