Headless cookie consent that actually blocks scripts.
TypeScript-first, SSR-safe, zero-dependency consent management. Works on the server (Node.js headers), on the client (cookies/localStorage), and with React via useSyncExternalStore -- no Provider required.
npm install @consentify/coreimport { createConsentify } from '@consentify/core';
const consent = createConsentify({
policy: { categories: ['analytics', 'marketing'] as const },
});
// Check consent (client-side)
consent.isGranted('analytics'); // false — not yet granted
// User accepts analytics
consent.set({ analytics: true });
consent.isGranted('analytics'); // trueThis is what consent management is actually for -- preventing tracking scripts from loading until the user explicitly opts in. guard() handles the entire lifecycle: wait for consent, load the script, and optionally clean up if consent is revoked.
// lib/consent.ts
import { createConsentify } from '@consentify/core';
export const consent = createConsentify({
policy: { categories: ['analytics', 'marketing'] as const },
cookie: { name: 'consent', sameSite: 'Lax', secure: true },
consentMaxAgeDays: 365,
});// Load GA only when analytics consent is granted
consent.guard('analytics', () => {
const s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX';
s.async = true;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
function gtag() { dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXXXXX');
});If the user has already consented, the script loads immediately. If not, guard() waits and fires once consent is granted -- no manual subscribe() wiring needed.
You can also handle revocation:
const dispose = consent.guard(
'marketing',
() => loadPixel(), // runs when marketing consent is granted
() => removePixel(), // runs if consent is later revoked
);
// Stop watching entirely
dispose();// Your cookie banner UI (framework-agnostic)
import { consent } from './lib/consent';
document.getElementById('accept-all')?.addEventListener('click', () => {
consent.set({ analytics: true, marketing: true });
});
document.getElementById('reject-all')?.addEventListener('click', () => {
consent.set({ analytics: false, marketing: false });
});
document.getElementById('reset')?.addEventListener('click', () => {
consent.clear();
window.location.reload();
});Built-in support for Google Consent Mode v2. No extra package needed.
import { createConsentify, enableConsentMode, defaultConsentModeMapping } from '@consentify/core';
const consent = createConsentify({
policy: { categories: ['analytics', 'marketing', 'preferences'] as const },
});
// Wire up Google Consent Mode with the default mapping
const dispose = enableConsentMode(consent, {
mapping: defaultConsentModeMapping,
waitForUpdate: 500,
});enableConsentMode automatically calls gtag('consent', 'default', ...) on init and gtag('consent', 'update', ...) whenever the user changes their choices. It bootstraps dataLayer and gtag if they don't exist.
You can also provide a custom mapping:
enableConsentMode(consent, {
mapping: {
necessary: ['security_storage'],
analytics: ['analytics_storage'],
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
},
});npm install @consentify/core @consentify/react// lib/consent.ts
import { createConsentify } from '@consentify/core';
export const consent = createConsentify({
policy: { categories: ['analytics', 'marketing'] as const },
});// components/CookieBanner.tsx
import { useConsentify } from '@consentify/react';
import { consent } from '../lib/consent';
export function CookieBanner() {
const state = useConsentify(consent);
if (state.decision === 'decided') return null;
return (
<div role="dialog" aria-label="Cookie consent">
<p>We use cookies to improve your experience.</p>
<button onClick={() => consent.set({ analytics: true, marketing: true })}>
Accept All
</button>
<button onClick={() => consent.set({ analytics: false, marketing: false })}>
Reject All
</button>
</div>
);
}// components/Analytics.tsx — only render tracking when consented
import { useConsentify } from '@consentify/react';
import { consent } from '../lib/consent';
export function Analytics() {
const state = useConsentify(consent);
if (state.decision !== 'decided' || !state.snapshot.choices.analytics) {
return null;
}
return <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX" />;
}No Provider or Context needed. useConsentify is powered by useSyncExternalStore -- it subscribes directly to the consent instance and re-renders on changes.
Consentify is SSR-safe out of the box. The server API reads and writes consent via raw Cookie / Set-Cookie headers -- no DOM required.
// app/layout.tsx (Next.js App Router)
import { cookies } from 'next/headers';
import { consent } from '../lib/consent';
import { CookieBanner } from '../components/CookieBanner';
import { Analytics } from '../components/Analytics';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const state = consent.get(cookieStore.toString());
return (
<html>
<body>
{children}
<CookieBanner />
{state.decision === 'decided' && state.snapshot.choices.analytics && <Analytics />}
</body>
</html>
);
}// app/api/consent/route.ts — Server Action to set consent
import { NextResponse } from 'next/server';
import { consent } from '../../../lib/consent';
export async function POST(request: Request) {
const { choices } = await request.json();
const cookieHeader = request.headers.get('cookie') ?? '';
const setCookie = consent.set(choices, cookieHeader);
const res = NextResponse.json({ ok: true });
res.headers.append('Set-Cookie', setCookie);
return res;
}getServerSnapshot() always returns { decision: 'unset' } during SSR, so hydration mismatches are impossible.
Returns a consent instance with flat top-level methods and server/client namespaces for advanced use.
| Option | Type | Default | Description |
|---|---|---|---|
policy.categories |
readonly string[] |
required | Consent categories (e.g., ['analytics', 'marketing']) |
policy.identifier |
string |
auto-hash | Stable policy version key. Changing it invalidates existing consent |
cookie.name |
string |
'consentify' |
Cookie name |
cookie.maxAgeSec |
number |
31536000 (1 year) |
Cookie max-age in seconds |
cookie.sameSite |
'Lax' | 'Strict' | 'None' |
'Lax' |
SameSite attribute |
cookie.secure |
boolean |
true |
Secure flag (forced true when sameSite: 'None') |
cookie.path |
string |
'/' |
Cookie path |
cookie.domain |
string |
— | Cookie domain |
consentMaxAgeDays |
number |
— | Auto-expire consent after N days |
storage |
StorageKind[] |
['cookie'] |
Client storage priority ('cookie', 'localStorage') |
| Method | Signature | Description |
|---|---|---|
get |
() => ConsentState<T> |
Current consent state (client-side) |
get |
(cookieHeader: string) => ConsentState<T> |
Read consent from a Cookie header (server-side) |
isGranted |
(category: string) => boolean |
Check a single category (client-side) |
set |
(choices: Partial<Choices<T>>) => void |
Update consent choices (client-side) |
set |
(choices: Partial<Choices<T>>, cookieHeader: string) => string |
Returns a Set-Cookie header string (server-side) |
clear |
() => void |
Clear all consent data (client-side) |
clear |
(serverMode: string) => string |
Returns a clearing Set-Cookie header (server-side) |
guard |
(category, onGrant, onRevoke?) => () => void |
Run code when consent is granted; optionally handle revocation. Returns a dispose function |
subscribe |
(cb: () => void) => () => void |
Subscribe to changes (React-compatible) |
getServerSnapshot |
() => ConsentState<T> |
Always returns { decision: 'unset' } for SSR |
The server and client namespaces are still available for direct access:
| Method | Signature | Description |
|---|---|---|
server.get |
(cookieHeader: string | null | undefined) => ConsentState<T> |
Read consent from a Cookie header |
server.set |
(choices: Partial<Choices<T>>, currentCookieHeader?: string) => string |
Returns a Set-Cookie header string |
server.clear |
() => string |
Returns a clearing Set-Cookie header |
client.get |
() => ConsentState<T> |
Current consent state |
client.get |
(category: string) => boolean |
Check a single category |
client.set |
(choices: Partial<Choices<T>>) => void |
Update consent choices |
client.clear |
() => void |
Clear all consent data |
client.guard |
(category, onGrant, onRevoke?) => () => void |
Guard with dispose |
client.subscribe |
(cb: () => void) => () => void |
Subscribe to changes |
client.getServerSnapshot |
() => ConsentState<T> |
Always { decision: 'unset' } |
Wires Google Consent Mode v2 to a consent instance. Returns a dispose function.
| Option | Type | Description |
|---|---|---|
mapping |
Partial<Record<category, GoogleConsentType[]>> |
Maps consent categories to Google consent types |
waitForUpdate |
number |
Milliseconds to wait before applying defaults (optional) |
Google consent types: ad_storage, ad_user_data, ad_personalization, analytics_storage, functionality_storage, personalization_storage, security_storage.
import { useConsentify } from '@consentify/react';
const state = useConsentify(consent);
// state: { decision: 'unset' } | { decision: 'decided', snapshot: Snapshot<T> }The 'necessary' category is always true and cannot be disabled. When you change your policy.categories (or policy.identifier), all existing consent is automatically invalidated -- users will be prompted again.
| Package | Description |
|---|---|
| @consentify/core | Headless consent SDK -- TypeScript-first, SSR-safe, zero dependencies |
| @consentify/react | React hook for @consentify/core |
A hosted consent management platform with a visual banner editor, analytics dashboard, and compliance reporting.
- Visual banner builder -- drag-and-drop consent UI
- Consent analytics dashboard -- see opt-in/out rates
- One-line integration -- single script tag setup
- Multi-language support -- GDPR-compliant translations
@consentify/next-- Next.js middleware with automatic cookie handling- Geo-aware consent defaults -- show banners only where required
If you find this project useful, consider supporting its development:
MIT © 2025 Roman Denysov