Skip to content

Headless cookie consent SDK - TypeScript-first, SSR-safe, zero deps

Notifications You must be signed in to change notification settings

RomanDenysov/consentify

Repository files navigation

consentify

consentify

Headless cookie consent that actually blocks scripts.

npm version CI bundle size zero deps TypeScript license

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.

Quick Start

npm install @consentify/core
import { 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'); // true

The Full Integration: Blocking Google Analytics Until Consent

This 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();
});

Google Consent Mode v2

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'],
  },
});

React Integration

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.

SSR / Next.js

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.

API Reference

createConsentify(init)

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')

Flat API (primary)

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

Server / Client Namespaces (advanced)

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' }

enableConsentMode(instance, options)

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.

useConsentify(instance) (React)

import { useConsentify } from '@consentify/react';

const state = useConsentify(consent);
// state: { decision: 'unset' } | { decision: 'decided', snapshot: Snapshot<T> }

Policy Versioning

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.

Packages

Package Description
@consentify/core Headless consent SDK -- TypeScript-first, SSR-safe, zero dependencies
@consentify/react React hook for @consentify/core

Coming Soon: Consentify SaaS

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.dev

Roadmap

  • @consentify/next -- Next.js middleware with automatic cookie handling
  • Geo-aware consent defaults -- show banners only where required

Support

If you find this project useful, consider supporting its development:

License

MIT © 2025 Roman Denysov

About

Headless cookie consent SDK - TypeScript-first, SSR-safe, zero deps

Topics

Resources

Contributing

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •