@mihilista/cookie-consent
v4.0.0
Published
GDPR-compliant cookie consent manager for Next.js with Google Consent Mode v2 and Meta Pixel integration. Ships English and Czech copy.
Maintainers
Readme
Next.js Cookie Consent Manager 🍪
A GDPR-compliant cookie consent manager for Next.js, with Google Consent Mode v2 and Meta Pixel integration built in. Ships React components, a context provider, and a consent-initialization script that wires correctly with Google Tag Manager.
✨ Features
- English and Czech built in:
locale="en"(default) orlocale="cs", with every string overridable on top - Google Consent Mode v2:
analytics_storage,ad_storage,ad_user_data,ad_personalization - Meta Pixel consent handling: automatic
fbq('consent', 'grant'|'revoke')with retry until pixel loads - Pre-GTM consent defaults:
<ConsentInitScript />setsdeniedbefore any tracking loads (GDPR-safe by default) - Region-aware variant:
<ConsentInitScriptWithRegions />for EEA/US/California differentiation - Cookie persistence: 365 days when accepted, 1 day when rejected
- Cross-subdomain consent: one decision shared between
example.comandapp.example.comviacookieDomain - Granular categories: Required (always on), Analytics, Marketing
- Accessible preferences modal: focus trap, focus restore, scroll lock, visible focus rings, and keyboard-reachable toggles. Closes via the X button, overlay click, or
Esc - Themeable styling: default CSS is a separate import; brand it via
--cc-*CSS variables or replace it entirely - Tested: 39 jsdom checks over consent storage, dataLayer events, cross-subdomain behaviour and locale resolution
📦 Installation
npm install @mihilista/cookie-consent
# or
pnpm add @mihilista/cookie-consent
# or
yarn add @mihilista/cookie-consentPeer deps: React >= 18.2, React DOM >= 18.2.
Upgrading from 3.x
The default copy is now English. If your site is Czech and you relied on the built-in strings, add one prop:
<CookieConsentProvider locale="cs">If you already pass your own locale object, nothing changes. Two smaller breaks are worth a look before you upgrade: the banner's body no longer suppresses the privacy-policy link (pass link: null for that), and the checkbox markup changed, which matters only if you wrote custom CSS against it. Full list in CHANGELOG.md.
🚀 Quick Start (Next.js App Router)
The implementation order is critical for GDPR compliance. ConsentInitScript must run before GTM so default consent is denied before any tag loads.
// app/layout.tsx
import '@mihilista/cookie-consent/styles'; // optional, see Styling below
import {
ConsentInitScript,
CookieConsentProvider,
CookieBanner,
CookiePreferencesModal,
} from '@mihilista/cookie-consent';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
{/* 1. CRITICAL: set default consent to denied BEFORE GTM */}
<ConsentInitScript />
{/* 2. Load Google Tag Manager AFTER ConsentInitScript */}
<script
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');`,
}}
/>
</head>
<body>
{/* 3. Wrap the app and mount the banner + modal */}
{/* locale defaults to "en"; pass locale="cs" for the built-in Czech copy */}
<CookieConsentProvider>
<CookieBanner />
<CookiePreferencesModal />
{children}
</CookieConsentProvider>
</body>
</html>
);
}For region-specific defaults (EEA denied, US granted, California denied), swap <ConsentInitScript /> for <ConsentInitScriptWithRegions />. See IMPLEMENTATION_GUIDE.md.
🧩 API
Components
| Component | Purpose |
|---|---|
| ConsentInitScript | Inline script for <head>. Sets default Google Consent to denied, re-applies any saved consent. Must precede GTM. |
| ConsentInitScriptWithRegions | Region-aware variant. Accepts regions={{ eea, us, california }}. |
| CookieConsentProvider | Context provider. Wraps the app, manages state, handles GTM/Meta Pixel consent updates. Accepts locale and cookieDomain. |
| CookieBanner | Bottom banner shown when no consent cookie exists. Accepts locale. |
| CookiePreferencesModal | Granular preferences modal. Accepts locale. Traps focus while open and closes on X button, overlay click, or Esc. |
| CookieConsent | Convenience wrapper that mounts Provider + Banner + Modal (use when you don't need custom layout). Accepts locale and cookieDomain. |
| CookiePreferencesButton | Pre-styled button that reopens the preferences modal. Label follows the active locale. |
Hook: useCookieConsent()
Returns:
preferences: { analytics: boolean; marketing: boolean }showBanner,setShowBannershowPreferencesModal,setShowPreferencesModalhandleAccept(): accept allhandleReject(): reject allsavePreferences(analytics?, marketing?): save current or explicit choicestogglePreference('analytics' | 'marketing')hasConsentCookie(): booleangetConsentCookie(): ConsentCookie | null, the stored decision in Consent Mode v2 shapelocale: ConsentLocale, the resolved dictionary in useCookiePreferencesButton: pre-styled button to reopen the modal (also a top-level export)
'use client';
import { useCookieConsent } from '@mihilista/cookie-consent';
export function CookieSettingsLink() {
const { CookiePreferencesButton } = useCookieConsent();
return <CookiePreferencesButton>Cookie settings</CookiePreferencesButton>;
}Cookie Storage
A single userConsent cookie is written in Google Consent Mode v2 format:
{
"analytics_storage": "granted" | "denied",
"ad_storage": "granted" | "denied",
"ad_user_data": "granted" | "denied",
"ad_personalization": "granted" | "denied"
}Lifetime: 365 days if any category is granted, 1 day otherwise (so rejecters get re-prompted reasonably soon).
Cross-Subdomain Consent (cookieDomain)
By default the userConsent cookie is host-only: written on example.com, it is never sent to app.example.com. A visitor who accepts on the marketing site is asked again in the app, and the app's Meta Pixel ignores the decision they already made.
Pass cookieDomain on both properties to share one decision:
<CookieConsentProvider cookieDomain=".example.com">
<CookieBanner />
<CookiePreferencesModal />
{children}
</CookieConsentProvider>Reading was never the problem (the browser hands a parent-domain cookie to every subdomain, so ConsentInitScript and useCookieConsent already saw it). Only the write needed the Domain attribute.
Rules:
- Use the leading dot form,
.example.com. Setting aDomainat all is what widens a cookie from host-only to the domain plus every subdomain; browsers strip the leading dot per RFC 6265 §5.2.3, soexample.combehaves identically, but the dotted spelling makes the cross-subdomain intent unmistakable. - It must cover the host being served, and must not be a public suffix like
.comor.co.uk. When the browser cannot accept the value, the package stores a host-only cookie instead (v3.4.0+), so the decision always persists, just unshared. Development buildsconsole.warnwith the reason; production builds stay silent. - It is safe to leave set in local development and on preview URLs.
.example.comcovers neitherlocalhostnor*.vercel.app, so consent there is simply stored host-only and the banner does not come back on every reload. No env var plumbing needed. - Omitting the prop changes nothing. No
Domainattribute is written and the cookie is byte for byte what earlier versions produced. - Enabling it on a live site leaves returning visitors holding both their old host-only cookie and the new shared one for a while. Both readers take the last match, which is the shared copy, and the leftover expires on its own schedule.
dataLayer Events
| Event | Pushed when | Payload |
|---|---|---|
| consent_updated | The visitor makes or changes a decision in this page view (banner or preferences modal) | consent_analytics: boolean, consent_marketing: boolean |
| consent_restored | A stored decision is re-applied on mount, i.e. a returning visitor | Same field names and types |
The payloads are identical by design, so a single GTM Custom Event trigger with Use regex matching and consent_updated|consent_restored covers both. consent_restored fires at most once per page load and never accompanies the consent_updated of the same decision.
Most tags do not need this trigger: GTM releases blocked tags by itself once gtag('consent', 'update', ...) grants what they require, and the provider calls that in both cases. Use the events for anything that must fire explicitly on a consent change.
🌐 Localization
The package ships two complete dictionaries, English (default) and Czech. Pick one with locale, override individual strings with an object, or supply your own translations entirely.
Pick a built-in language
<CookieConsentProvider locale="cs">
<CookieBanner />
<CookiePreferencesModal />
{children}
</CookieConsentProvider>One prop covers every visible string: the banner, the modal, the preferences button label, the modal's close-button label and the banner's landmark name. Omit it and you get English.
Override individual strings
Pass an object instead of a language. Overrides deep-merge onto the active dictionary, so you write only what differs and the rest stays translated:
<CookieConsentProvider
locale={{
language: 'cs',
banner: { title: 'Vlastní titulek' },
modal: { buttons: { save: 'Uložit' } },
}}
>language names the dictionary the overrides build on and defaults to en.
Per-component overrides
CookieBanner and CookiePreferencesModal take the same prop and win over the provider for themselves:
<CookieBanner locale={{ linkHref: '/legal/privacy' }} />
<CookiePreferencesModal locale="cs" />The privacy-policy link
The banner appends a link after its body text. Three ways to steer it:
| Goal | What to pass |
|---|---|
| Change where it points or what it says | linkHref and linkText |
| Use a framework link | link: <Link href="/legal/privacy">Privacy Policy</Link> |
| No generated link, because body already contains one | link: null |
A passed link element keeps its own className and gains cc--banner--typo--bodyLink.
Changed in 4.0. Setting
bodyused to remove the link silently. The two are independent now, so abodywith its own link must also passlink: null.
Driving the language from your router
A router locale is a string, and the locale prop wants 'en' | 'cs'. isConsentLanguage narrows it:
import { CookieConsentProvider, isConsentLanguage } from '@mihilista/cookie-consent';
const { locale } = await params;
<CookieConsentProvider locale={isConsentLanguage(locale) ? locale : 'en'}>With next-intl
// components/cookie-banner-and-modal.tsx
'use client';
import { CookieBanner, CookiePreferencesModal } from '@mihilista/cookie-consent';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
export default function CookieBannerAndModal() {
const t = useTranslations('cookies');
const bannerLocale = {
title: t('banner.title'),
body: t.rich('banner.body', {
policy: (chunks) => (
<Link href="/legal/privacy-policy" className="underline">
{chunks}
</Link>
),
}),
// the rich body already carries the link
link: null,
buttons: {
manage: t('banner.buttons.manage'),
accept: t('banner.buttons.accept'),
reject: t('banner.buttons.reject'),
},
};
return (
<>
<CookieBanner locale={bannerLocale} />
<CookiePreferencesModal locale={t.raw('modal')} />
</>
);
}Building a third language
Export the built-in dictionaries, copy the shape, and pass the result as the locale object:
import { en, type ConsentLocale } from '@mihilista/cookie-consent';
const de: ConsentLocale = {
...en,
banner: { ...en.banner, title: 'Cookies verbessern Ihr Erlebnis' },
};
<CookieConsentProvider locale={de}>LOCALES ({ en, cs }) and DEFAULT_LANGUAGE are exported too, along with the ConsentLocale, BannerLocale and ModalLocale types, so a missing key is a compile error rather than a blank button.
🎨 Styling
Option 1: Use default styles
import '@mihilista/cookie-consent/styles';The same stylesheet is also published as a plain CSS file, for setups where a JS module that imports CSS is awkward:
@import '@mihilista/cookie-consent/styles.css';Option 1b: Theme via CSS variables (recommended)
Import the default styles, then override the --cc-* custom properties on
:root to match your brand. Every color, radius and shadow is a variable; the
defaults reproduce the original dark look, so you only set what you want to
change.
:root {
--cc-bg: #fffdf8; /* container background */
--cc-fg: #1a1a1a; /* text */
--cc-radius: 0.75rem; /* container corners */
--cc-button-radius: 0.375rem;/* button + checkbox corners */
--cc-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.25);
--cc-shadow-modal: 0 20px 60px -15px rgba(0, 0, 0, 0.35);
--cc-border: rgba(0, 0, 0, 0.12);
--cc-action-bg: #1a1a1a; /* primary button (accept / save) */
--cc-action-fg: #fffdf8;
--cc-action-border: #1a1a1a;
--cc-action-bg-hover: #333;
--cc-action-border-hover: #333;
--cc-overlay: rgba(0, 0, 0, 0.5);
--cc-close: rgba(0, 0, 0, 0.6);
--cc-close-hover: #1a1a1a;
--cc-checkbox-border: rgba(0, 0, 0, 0.12);
--cc-checkbox-checked-bg: #1a1a1a;
--cc-checkbox-checked-fg: #fffdf8;
--cc-focus: #1a1a1a; /* keyboard focus ring */
--cc-focus-width: 2px;
--cc-focus-offset: 2px;
}Because these are plain CSS variables, you can point them at your own design
tokens (e.g. --cc-bg: var(--color-card)) so the banner tracks your palette
automatically. The full list lives in dist/cookie-consent.css.
--cc-focus defaults to currentColor, which is legible on the default dark
surface. Set it explicitly if you re-theme the background, and keep the contrast:
the focus ring is the only thing telling a keyboard user where they are.
Option 2: Bring your own CSS
Skip the styles import. All components carry classes with the cc-- prefix (e.g. cc--banner--container, cc--modal--overlay, cc--buttonBase). dist/cookie-consent.css in the installed package is the authoritative list.
Two rules a replacement stylesheet has to honour, because accessibility depends on them:
- Keep
.cc--modal--checkboxInputvisually hidden but focusable. Hiding it withdisplay: noneorvisibility: hiddenremoves it from the tab order, and the consent categories become impossible to change by keyboard. Use the clip-rect pattern the default stylesheet uses. - Give every control a visible
:focus-visiblestyle. The defaults setoutline: noneon buttons and put the ring back on:focus-visible; if you drop the second half, keyboard users get no indicator at all.
Option 3: Hybrid
import '@mihilista/cookie-consent/styles';
import './cookie-consent-overrides.css';🛡️ GDPR Compliance
This package handles the consent state correctly, but GTM tag configuration is your responsibility. Every analytics/marketing tag in your GTM container must be configured with consent requirements, otherwise tags fire regardless of user choice.
Minimum tag setup:
- GA4: require
analytics_storage - Meta Pixel: require
ad_storage,ad_user_data,ad_personalization. Pixel code must start withfbq('consent', 'revoke'). - Google Ads: require
ad_storage,ad_user_data,ad_personalization
Full walkthrough in IMPLEMENTATION_GUIDE.md.
📖 Further Reading
- IMPLEMENTATION_GUIDE.md: step-by-step GDPR-compliant setup, GTM tag config, testing
- Google Consent Mode v2
- Meta Pixel & GDPR
