Cookie Consent
A cookie consent banner with categories, a preferences dialog, helpers for gating scripts on consent, and consent that is stored on the signed-in user as proof.
@frigear-nu/module-kit and @frigear-nu/ui (a hard dependency of the package;
the components also need @nuxt/ui in the host).
Optional@frigear-nu/users (with NuxtHub) to store the consent on the user, and
@frigear-nu/auth, declared only so it is set up first. Without users, consent lives only in the cookie.Design
- Opt-in per category.
necessaryis always on; every other category is off until the visitor grants it. Rejecting is exactly as easy as accepting. - Specific and informed. Each category can list its cookies (name, provider, purpose, duration); they are shown in the preferences dialog.
- Versioned. The consent version is your
versionoption plus a hash of the category/cookie set. Adding a category or cookie (including from another module) asks everyone again. The oldaccepted/rejectedcookie values are migrated. - Provable. For signed-in users the timestamp, version and granted categories are stored on the
usersrow.
Quick setup
export default defineNuxtConfig({
modules: ['@nuxt/ui', '@frigear-nu/cookie-consent'],
cookieConsent: {
version: '1', // bump to ask everyone again
policyUrl: '/legal/gdpr',
},
})
<!-- app.vue, inside <UApp> -->
<CookieConsentBanner />
Put <CookieConsentManage /> (a "Manage cookies" button) in the footer so visitors can change their mind. It opens the
preferences dialog rendered by the banner.
Options
All options live under cookieConsent.
| Option | Type | Default | Purpose |
|---|---|---|---|
cookieName | string | 'cookie-consent' | Name of the consent cookie. |
maxAge | number | 15552000 (180 days) | Cookie lifetime in seconds. |
version | string | '1' | Manual version; changing it asks every visitor again. The category set is hashed into the final version. |
categories | ConsentCategory[] | necessary (required), analytics, marketing | Categories to show. Other modules add more with extendCookieConsent. |
excludeRoutes | string[] | ['/authorize'] | Exact routes where the banner is hidden. |
collapseOnScroll | boolean | number | true | Collapse the banner into a round button after this many px scrolled (true = 80, false = never). |
policyUrl | Translated | none | Link to the cookie/privacy policy shown in the banner. |
policyLabel | Translated | i18n key cookieConsent.policy ("Read more") | Text of the policy link; wins over the translation. |
persistToUser | boolean | true when @frigear-nu/users is installed | Store the consent on the signed-in user. Forced off without users. |
icon | string | 'lucide:cookie' | Iconify name (collection:name) of the badge and collapsed button icon. |
Translated is a string or a map of locale code to value ({ da, en }). A locale without an entry falls back to its
base language, then en, then the first entry.
export default defineNuxtConfig({
cookieConsent: {
policyUrl: { en: '/legal/gdpr', da: '/da/juridisk/gdpr' },
policyLabel: { en: 'Read our cookie policy', da: 'Læs vores cookiepolitik' },
categories: [
{ key: 'necessary', required: true, label: { en: 'Necessary', da: 'Nødvendige' } },
{ key: 'analytics', label: 'Analytics', cookies: [{ name: '_ga', provider: 'Google', duration: '2 years' }] },
],
},
})
The icon is added to Nuxt Icon's client bundle by the module (through the icon:clientBundleIcons hook), because Nuxt
Icon does not scan node_modules; no icon.clientBundle.icons entry is needed. Icons from custom collections work as
long as the collection is configured in Nuxt Icon.
Types
interface ConsentCookie {
name: string
provider?: string
purpose?: Translated
duration?: string // human readable, e.g. '1 year' or 'Session'
}
interface ConsentCategory {
key: string
required?: boolean // always on and not switchable
defaultEnabled?: boolean // pre-selected in the preferences dialog; never applied without the visitor saving. Default false
label?: Translated // default: i18n key cookieConsent.categories.<key>.label
description?: Translated // default: i18n key cookieConsent.categories.<key>.description
cookies?: ConsentCookie[]
}
/** What is stored in the consent cookie and, for signed-in users, in the database. */
interface ConsentState {
v: string // consent version the choice was made against
ts: number // epoch ms
categories: Record<string, boolean>
}
Server
getCookieConsent(event: H3Event): ConsentState | null // null = undecided or outdated
hasCookieConsent(event: H3Event, category: string): boolean // gate tracking in SSR / API code
getCookieConsentConfig(event: H3Event): PublicModuleOptions | undefined
getCookieConsent and hasCookieConsent are auto-imported in server code.
App
Composables
const {
config, categories, consent, hasResponded, isAllowed,
acceptAll, rejectAll, save, reset,
isPreferencesOpen, openPreferences, hydrateFromUser,
} = useCookieConsent()
isAllowed(key: string): boolean
save(choices: Record<string, boolean>): Promise<ConsentState> // required categories are always granted
reset(): Promise<void> // withdraws consent: only required categories remain and the banner shows again
// Runs now if already granted, or as soon as it is; the returned function runs on withdrawal.
onCookieConsent(category: string, callback: () => (() => void) | undefined): () => void // returns a stop function
useConsentT() // translated texts of the module
onCookieConsent('analytics', () => {
const stop = startTracking()
return () => stop()
})
Components
The default components are registered with the lowest priority. A component with the same name in your app (or an extended layer) wins, with no module option, including where other module components use it.
| Component | Replaces |
|---|---|
CookieConsentBanner | The whole banner, including the collapsed round button |
CookieConsentBannerActions | Only the button row of the banner (preferences / reject / accept) |
CookieConsentPreferences | The preferences dialog |
CookieConsentManage | The "Manage cookies" footer button |
CookieConsentBanner renders CookieConsentBannerActions and CookieConsentPreferences itself, so replacing one part
keeps the defaults of the others. A replacement banner builds on useCookieConsent() and should call
hydrateFromUser() and the cookie-consent:ready hook on mount, as the default one does. Copy a default from
packages/cookie-consent/src/runtime/app/components as a starting point.
Contributing categories (from a module)
Other modules add categories or cookies during setup(); they are merged by key, in any order, after every module has
set up (earlier labels win, cookies are concatenated, required is sticky).
import { extendCookieConsent, defineConsentCategory } from '@frigear-nu/cookie-consent/utils'
extendCookieConsent(nuxt, {
categories: [
defineConsentCategory({ key: 'necessary', required: true, cookies: [{ name: 'stripe_mid', provider: 'Stripe', duration: '1 year', purpose: 'Fraud prevention' }] }),
],
})
Utils
@frigear-nu/cookie-consent/utils is framework-agnostic and safe in a module's setup(), server code and tests.
| Export | Purpose |
|---|---|
extendCookieConsent(nuxt, { categories }) | Contribute categories; calls the cookie-consent:extend hook. |
defineConsentCategory(category) | Identity helper for typing a category. |
DEFAULT_CATEGORIES | necessary, analytics, marketing. |
mergeCategories(...lists) | Merge category lists by key. |
computeVersion(manual, categories) | Final consent version (manual version plus category hash). |
buildConsent(categories, version, choices, now?) | Build a ConsentState; required categories granted, unknown keys dropped. |
normalizeConsent(raw, categories, version, now?) | Cookie value (including legacy strings) to a valid state, or null when the visitor must be asked again. |
acceptAllChoices / rejectAllChoices | Choice maps for accept all / reject all. |
localize(value, locale, fallback?) | Resolve a Translated value. |
Types ConsentCategory, ConsentCookie, ConsentState and Translated are exported from the same path.
Hooks
| Hook | Where | When | Payload |
|---|---|---|---|
cookie-consent:extend | Nuxt (build) | extendCookieConsent registers categories | (categories: ConsentCategory[]) |
cookie-consent:resolved | Nuxt (build) | Once, after every module has set up | (categories: ConsentCategory[]); mutate to adjust, the version is derived afterwards |
cookie-consent:change | Nuxt app (client) | Every save, accept, reject or withdrawal, and when a stored choice is picked up from the user | (state: ConsentState | null, previous: ConsentState | null); state is null on withdrawal |
cookie-consent:ready | Nuxt app (client) | Banner mounted | (state: ConsentState | null); null while undecided |
cookie-consent:saved | Nitro | A signed-in user's consent was stored | ({ userId: number | string, state: ConsentState, acceptedAt: number }) |
Routes
Registered only with persistToUser enabled, which requires @frigear-nu/users.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/cookie-consent | Session only | The signed-in user's stored consent, { consent: ConsentState | null }. A returning user without a cookie on a new device gets their choice back (unless the version changed). |
POST | /api/cookie-consent | Session only | Store { categories, version }. 409 for an outdated version, 400 for unknown categories. The server sets the timestamp and forces required categories. Calls cookie-consent:saved. |
Database
With persistToUser the module extends users (SQLite schema):
| Column | Meaning |
|---|---|
cookie_consent_accepted_at | Epoch ms of the latest response |
cookie_consent_version | Consent version answered against |
cookie_consent_categories | JSON of what was granted |
extendDatabaseTable only changes the generated schema, so generate and commit migrations in every consuming app
and fixture. See Migrations.
Translations
Namespace cookieConsent, shipped in da and en and contributed to @nuxtjs/i18n (see
Module Kit). Without @nuxtjs/i18n the components fall back to English.
Override any string in your app's i18n/locales/<code>.json (see
overriding module strings).
| Key | Used for |
|---|---|
cookieConsent.description, accept, reject, preferences, expand, manage, save | Banner and buttons |
cookieConsent.preferencesTitle, preferencesDescription, required, cookies | Preferences dialog |
cookieConsent.policy | Policy link text when policyLabel is not set |
cookieConsent.categories.<key>.label / .description | Category texts; custom categories use the same keys, or set label / description in the config |