Skip to content
← Back

react-consent-shield: Why your cookie banner doesn't comply with GDPR and how to fix it without spending weeks

12 min read
ReactTypeScriptNPMPrivacyGDPRCCPACookies

A user visits your site and Google Analytics has already tracked their visit before the cookie banner appears. When they click 'Reject All', their data is already on Google, Meta, and TikTok servers. Most consent solutions just show a popup and hope for the best. This React library intercepts scripts at DOM level with MutationObserver, supports 52 privacy laws with automatic geo-detection, includes 274 pre-configured services, and generates audit reports with cryptographic verification

A user lands on your website. It takes 200 milliseconds to load the DOM, another 300 for React to hydrate, and the consent banner appears half a second later. By then, Google Analytics has already sent the page_view event, Meta Pixel has fired PageView, and Hotjar has started recording the session. The user sees the banner, thinks for a moment, and clicks “Reject All”. But their data is already on its way to servers in the United States, Ireland, and Singapore.

This scenario happens on the vast majority of websites that “comply” with GDPR. Technically they have a banner. Technically they save preferences. But tracking scripts load before the user has a chance to decide. It’s cosmetic compliance, not real compliance.

The problem multiplies when your application serves users from different countries. GDPR in Europe requires explicit opt-in before any tracking. CCPA in California allows opt-out, but you have to respect the “Do Not Sell” signal. LGPD in Brazil has its own rules. POPIA in South Africa, PDPA in Thailand, PIPL in China. There are 52 different privacy laws with requirements ranging from “you can track by default” to “you need explicit consent for each individual service”.

Implementing this correctly means weeks of work. Researching each law, implementing geo-detection, configuring each analytics service, creating a category system, handling granular consent, generating audit logs for when the data protection auditor comes. And after all that work, you discover your implementation has a bug and scripts are still loading before consent.

Why most banners don’t really comply

The fundamental problem is execution order. In a typical React application, HTML includes script tags in the <head> or at the end of <body>. These scripts start executing before React mounts any component. Your consent banner is a React component that renders after the application hydrates. By the time the banner exists in the DOM, tracking scripts have been running for hundreds of milliseconds.

Some solutions try to fix this with CSS. They add display: none to content until the user consents. This visually hides content but doesn’t prevent scripts from executing. Google Analytics still counts the visit. Conversion pixels still fire.

Other solutions use the type="text/plain" attribute on tracking scripts and change it to type="text/javascript" after consent. This works for scripts you control, but not for scripts that are dynamically injected. Google Tag Manager, for example, injects additional scripts after loading. Blocking GTM’s initial script doesn’t block the scripts GTM injects later.

The most problematic case is third-party scripts that load more scripts. Meta Pixel loads an initial script that then loads additional modules based on configuration. TikTok Pixel does the same. Hotjar loads a script that then loads the session recorder. Blocking the parent script doesn’t guarantee blocking the children if the load already triggered.

The only real solution is to intercept at the DOM level. It’s not enough to control which scripts you include in your HTML. You need to intercept any script that tries to add itself to the DOM, wherever it comes from, and block it until valid consent exists.

Why I created this library

I needed to implement cookie consent for a SaaS application with international presence. Users in Europe requiring strict GDPR with explicit opt-in. Users in California under CCPA with opt-out and “Do Not Sell” requirements. Users in Brazil with LGPD, in Canada with PIPEDA, in Asia with PDPA variations. One product, dozens of different laws, and the expectation that everything would work without the development team spending months researching privacy legislation.

Existing solutions had limitations. Paid services like Cookiebot or OneTrust cost hundreds of euros per month and add latency because they load from their servers. The open source libraries I found either just showed the banner without actually blocking scripts, or required manual configuration of each service, or didn’t support geo-detection.

What I needed was a library that actually blocked scripts at the DOM level, that automatically detected the user’s location and applied the correct law, that had the most common services pre-configured so I didn’t have to research what cookies each one uses, and that generated audit logs because data protection auditors ask for them.

The result is react-consent-shield: real script blocking with MutationObserver, 52 privacy laws with geo-detection, 274 pre-configured services from Google Analytics to Yandex Metrica, granular consent system at the individual service level, cookie scanner to detect undeclared cookies, and audit report generation with cryptographic verification.

Installation and basic usage

npm install react-consent-shield

The simplest case requires three components: the provider that manages state, the banner that shows options, and optionally the modal for detailed configuration:

import {
  ConsentProvider,
  ConsentBanner,
  ConsentModal,
  googleAnalytics,
  metaPixel,
} from 'react-consent-shield';

function App() {
  return (
    <ConsentProvider
      config={{
        services: [googleAnalytics, metaPixel],
      }}
    >
      <YourApp />
      <ConsentBanner />
      <ConsentModal />
    </ConsentProvider>
  );
}

With this minimal configuration, Google Analytics and Meta Pixel scripts are blocked until the user consents. The banner appears automatically for new users. Preferences are persisted in localStorage and respected on subsequent visits.

Services are imported as presets. Each preset includes the script patterns to block, the cookie names the service creates, the consent category it belongs to, and the information shown to the user about what data it collects.

How real script blocking works

The library uses MutationObserver to intercept DOM modifications. When any code tries to add a <script> element to the document, the observer detects it before the browser processes the script.

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    mutation.addedNodes.forEach((node) => {
      if (node.nodeName === 'SCRIPT') {
        const src = node.getAttribute('src');
        if (shouldBlock(src)) {
          // Convert to inert element
          node.type = 'text/blocked';
          node.setAttribute('data-consent-blocked', 'true');
          node.setAttribute('data-original-src', src);
          node.removeAttribute('src');
        }
      }
    });
  });
});

observer.observe(document.documentElement, {
  childList: true,
  subtree: true,
});

The observer activates before React mounts. It’s registered in the document’s <head> as an inline script that executes immediately. This guarantees that any tracking script that tries to load, whether in the initial HTML or dynamically injected by another script, goes through the filter.

When the user gives consent, blocked scripts are restored:

function unblockScripts(category: string) {
  const blocked = document.querySelectorAll(
    `script[data-consent-blocked="true"][data-category="${category}"]`
  );

  blocked.forEach((script) => {
    const newScript = document.createElement('script');
    newScript.src = script.getAttribute('data-original-src');
    newScript.type = 'text/javascript';
    script.parentNode.replaceChild(newScript, script);
  });
}

This approach has an important advantage: it works with any script, not just ones you control. If a third-party script tries to inject another tracking script, it also gets blocked. If a tag manager tries to load additional pixels, they also get blocked. Blocking happens at the DOM level, not at the configuration level.

Automatic geographic detection

Manually implementing the logic of “if user is in Germany apply GDPR, if in California apply CCPA, if in Brazil apply LGPD” is tedious and error-prone. The library includes geo-detection that automatically determines which law applies.

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel],
    geoDetection: {
      enabled: true,
      fallbackLaw: 'GDPR',
    },
  }}
>

Detection uses the browser’s geolocation API when available, with fallback to timezone and browser language detection. The result determines which privacy law applies:

RegionLawBehavior
EU (27 countries)GDPRMandatory opt-in, explicit consent
United KingdomUK-GDPRMandatory opt-in, post-Brexit
CaliforniaCCPAOpt-out allowed, “Do Not Sell”
Virginia, Colorado, ConnecticutUS state lawsOpt-out variations
BrazilLGPDOpt-in for sensitive data
CanadaPIPEDAImplicit consent allowed in some cases
South AfricaPOPIAOpt-in with exceptions
Singapore, ThailandPDPARegional variations
ChinaPIPLStrict localization requirements
40+ othersLocal lawsJurisdiction-specific configuration

The fallbackLaw determines which rules to apply when detection fails or the user is in a region without a specific privacy law. Setting GDPR as fallback means applying the strictest rules by default, which guarantees compliance in the worst case.

Each law has different configuration for cookie categories. GDPR requires consent for analytics and marketing. CCPA allows analytics by default but requires an opt-out option for data “sale”. Some Asian laws allow functional cookies without consent but require opt-in for personalization.

The 274 pre-configured services

Researching what cookies Google Analytics creates, what domains Meta Pixel uses, what scripts Hotjar loads, consumes hours. The library includes 274 pre-configured services with all this information:

import {
  // Analytics
  googleAnalytics,
  googleTagManager,
  metaPixel,
  tiktokPixel,
  hotjar,
  mixpanel,
  amplitude,
  segment,
  heap,
  fullstory,
  microsoftClarity,

  // Advertising
  googleAds,
  metaAds,
  linkedInAds,
  twitterAds,
  pinterestTag,
  snapchatPixel,
  criteo,

  // Regional
  yandexMetrica,    // Russia
  baiduAnalytics,   // China
  naverAnalytics,   // South Korea

  // Privacy-focused
  plausible,
  fathom,
  simpleAnalytics,
} from 'react-consent-shield';

Each preset is an object with the service’s complete configuration:

const googleAnalytics: ServicePreset = {
  id: 'google-analytics',
  name: 'Google Analytics',
  category: 'analytics',
  description: 'Traffic analysis and user behavior',
  privacyPolicy: 'https://policies.google.com/privacy',
  scripts: [
    /googletagmanager\.com\/gtag/,
    /google-analytics\.com\/analytics/,
    /googleanalytics\.com/,
  ],
  cookies: [
    { name: '_ga', duration: '2 years', description: 'Distinguishes users' },
    { name: '_gid', duration: '24 hours', description: 'Distinguishes users' },
    { name: '_gat', duration: '1 minute', description: 'Throttles request rate' },
  ],
  dataCollected: [
    'Pages visited',
    'Time on page',
    'Traffic source',
    'Device and browser',
    'Approximate location',
  ],
};

The scripts patterns are regular expressions that match URLs of scripts to block. The library intercepts any script whose URL matches any of these patterns.

The cookies list the cookies the service creates. This information is shown to the user in the detailed configuration modal and used by the cookie scanner that detects undeclared cookies.

For services not in the list, you can create custom presets:

const myCustomService: ServicePreset = {
  id: 'my-analytics',
  name: 'My Custom Analytics',
  category: 'analytics',
  scripts: [/my-analytics\.com\/track/],
  cookies: [
    { name: 'ma_id', duration: '1 year', description: 'User ID' },
  ],
};

<ConsentProvider
  config={{
    services: [googleAnalytics, myCustomService],
  }}
>

Google introduced Consent Mode as a way to communicate consent state to their services. Version 2, mandatory since March 2024 for sites using Google Ads in the EEA, adds additional signals for personalized advertising.

The library integrates Consent Mode automatically:

<ConsentProvider
  config={{
    services: [googleAnalytics, googleAds],
    googleConsentMode: {
      enabled: true,
      defaultState: {
        analytics_storage: 'denied',
        ad_storage: 'denied',
        ad_user_data: 'denied',
        ad_personalization: 'denied',
        functionality_storage: 'granted',
        personalization_storage: 'denied',
        security_storage: 'granted',
      },
    },
  }}
>

When the user modifies their consent, the library automatically updates Consent Mode signals:

// User accepts analytics but rejects ads
gtag('consent', 'update', {
  analytics_storage: 'granted',
  ad_storage: 'denied',
  ad_user_data: 'denied',
  ad_personalization: 'denied',
});

Google Analytics in denied mode still sends pings but without cookies or persistent identifiers. This allows getting aggregate traffic data while respecting the user’s decision. Google Ads with ad_storage: denied can’t do remarketing but can still attribute conversions in aggregate.

The useGoogleConsentMode hook allows accessing the current state of signals:

function ConsentDebugger() {
  const { consentState, updateConsent } = useGoogleConsentMode();

  return (
    <div>
      <p>Analytics: {consentState.analytics_storage}</p>
      <p>Ads: {consentState.ad_storage}</p>
      <p>Ad User Data: {consentState.ad_user_data}</p>
      <p>Ad Personalization: {consentState.ad_personalization}</p>
    </div>
  );
}

Most banners offer three buttons: “Accept All”, “Reject All”, and “Customize”. Configuration is usually by broad categories: analytics, marketing, functional. This doesn’t give real control to the user who wants to allow Google Analytics but not Meta Pixel, or who wants Hotjar for feedback but doesn’t want their sessions recorded.

The library supports three levels of granularity:

Level 1: All or nothing

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel, hotjar],
    granularConsent: false,
  }}
>

The user accepts or rejects everything. Simple but inflexible.

Level 2: By categories

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel, hotjar],
    granularConsent: true,
    categories: {
      analytics: { required: false, defaultEnabled: false },
      marketing: { required: false, defaultEnabled: false },
      functional: { required: true, defaultEnabled: true },
    },
  }}
>

The user can accept analytics but reject marketing. Services are automatically grouped by their category in the preset.

Level 3: By individual service

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel, hotjar, mixpanel, amplitude],
    granularConsent: true,
    serviceLevel: true,
  }}
>

The user sees each service individually and can decide: “I want Google Analytics but not Mixpanel. I want Hotjar but only the feedback widget, not session recording.”

The detailed configuration modal shows each service with its description, what data it collects, what cookies it creates, and a link to its privacy policy:

<ConsentModal
  showServiceDetails={true}
  showCookieInfo={true}
  showDataCollected={true}
  showPrivacyLinks={true}
/>

For implementations that need programmatic control, hooks expose the complete state:

function MyCustomUI() {
  const {
    services,
    categories,
    isServiceEnabled,
    toggleService,
    toggleCategory,
    enableAll,
    disableAll,
  } = useConsent();

  return (
    <div>
      {services.map(service => (
        <label key={service.id}>
          <input
            type="checkbox"
            checked={isServiceEnabled(service.id)}
            onChange={() => toggleService(service.id)}
          />
          {service.name}
        </label>
      ))}
    </div>
  );
}

The undeclared cookies problem is common. You install a WordPress plugin and it creates cookies you didn’t know about. A third-party script injects additional tracking. A developer adds localStorage for something and nobody documents it.

The cookie scanner detects cookies that exist in the browser but aren’t declared in any configured service:

import { useCookieScanner } from 'react-consent-shield';

function CookieAudit() {
  const { scan, results, isScanning } = useCookieScanner();

  useEffect(() => {
    scan();
  }, []);

  if (isScanning) return <p>Scanning cookies...</p>;

  return (
    <div>
      <h3>Declared cookies ({results.declared.length})</h3>
      <ul>
        {results.declared.map(cookie => (
          <li key={cookie.name}>
            {cookie.name} - {cookie.service}
          </li>
        ))}
      </ul>

      <h3>Undeclared cookies ({results.undeclared.length})</h3>
      <ul>
        {results.undeclared.map(cookie => (
          <li key={cookie.name} style={{ color: 'red' }}>
            {cookie.name} - UNKNOWN ORIGIN
          </li>
        ))}
      </ul>
    </div>
  );
}

The scanner compares current browser cookies against the list of cookies declared in all configured services. Cookies that don’t match any service appear as “undeclared”.

This is critical for compliance audits. A data protection auditor may request the list of all cookies your site creates. If there are cookies not documented in your privacy policy, you have a problem. The scanner lets you detect this before the auditor does.

The scanner also detects third-party cookies created without your knowledge:

const { results } = useCookieScanner({
  includeThirdParty: true,
  includeSessionStorage: true,
  includeLocalStorage: true,
});

// results.thirdParty contains cookies from other domains
// results.localStorage contains localStorage keys
// results.sessionStorage contains sessionStorage keys

Audit logging and reports for audits

When a data protection auditor visits, they need to see evidence that your consent system works correctly. It’s not enough to show them the banner. They want to see records of when each user gave consent, what options they chose, if they changed their mind, and that these records haven’t been tampered with.

The library generates audit logs with cryptographic verification:

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel],
    auditLog: {
      enabled: true,
      hashAlgorithm: 'SHA-256',
      includeUserAgent: true,
      includeTimestamp: true,
      storage: 'localStorage', // or 'server' to send to your backend
    },
  }}
>

Each consent action generates a record:

{
  id: 'consent-log-1701432000000',
  timestamp: '2025-12-01T12:00:00.000Z',
  action: 'consent-updated',
  previousState: {
    analytics: false,
    marketing: false,
  },
  newState: {
    analytics: true,
    marketing: false,
  },
  services: {
    'google-analytics': true,
    'meta-pixel': false,
    'hotjar': true,
  },
  userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',
  geoLocation: 'DE',
  lawApplied: 'GDPR',
  hash: 'a1b2c3d4e5f6...',
  previousHash: '9z8y7x6w5v...',
}

The hash field is a SHA-256 of the record content plus the previous record’s hash. This creates a chain where modifying any record invalidates all subsequent ones. If the auditor sees that hashes don’t match, they know someone tampered with the logs.

To access logs programmatically:

import { useAuditLog } from 'react-consent-shield';

function AuditViewer() {
  const { logs, exportLogs, verifyIntegrity } = useAuditLog();

  const handleExport = () => {
    const data = exportLogs({ format: 'json' });
    downloadFile(data, 'consent-audit-log.json');
  };

  const handleVerify = async () => {
    const isValid = await verifyIntegrity();
    alert(isValid ? 'Logs intact' : 'ALERT: Logs tampered');
  };

  return (
    <div>
      <button onClick={handleExport}>Export logs</button>
      <button onClick={handleVerify}>Verify integrity</button>

      <table>
        <thead>
          <tr>
            <th>Date</th>
            <th>Action</th>
            <th>Services</th>
            <th>Hash</th>
          </tr>
        </thead>
        <tbody>
          {logs.map(log => (
            <tr key={log.id}>
              <td>{new Date(log.timestamp).toLocaleString()}</td>
              <td>{log.action}</td>
              <td>{Object.keys(log.services).filter(s => log.services[s]).join(', ')}</td>
              <td>{log.hash.substring(0, 16)}...</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

The library also generates compliance reports in JSON and HTML formats:

import { generateComplianceReport } from 'react-consent-shield';

const report = generateComplianceReport({
  format: 'html', // or 'json'
  includeServiceDetails: true,
  includeCookieInventory: true,
  includeAuditSummary: true,
  includeLawConfiguration: true,
});

// The report includes:
// - List of all configured services
// - Complete cookie inventory
// - Audit log summary
// - Configuration for each privacy law
// - Blocked and unblocked scripts
// - Cookie scanner results

The HTML report is formatted for printing and delivering to auditors. It includes tables with all services, their cookies, what data they collect, and the current consent state.

Additional features

Age verification

For sites accessible to minors, COPPA (USA) and GDPR-K (GDPR Article 8) require age verification:

<ConsentProvider
  config={{
    services: [googleAnalytics],
    ageVerification: {
      enabled: true,
      method: 'birthdate', // 'checkbox' | 'year' | 'birthdate'
      minimumAge: 16,
      parentalConsentRequired: true,
      blockTrackingIfUnderage: true,
    },
  }}
>

If the user indicates they’re under the minimum age, all tracking scripts remain blocked regardless of other options.

For applications with multiple subdomains (app.example.com, blog.example.com, shop.example.com), consent given on one should apply to all:

<ConsentProvider
  config={{
    services: [googleAnalytics],
    storage: {
      type: 'cookie',
      domain: '.example.com', // Note the leading dot
      sameSite: 'lax',
    },
  }}
>

The consent cookie is created with domain .example.com, making it accessible from any subdomain.

When you add a new service or change configuration, you may need to request consent again:

<ConsentProvider
  config={{
    services: [googleAnalytics, metaPixel, newService],
    consentVersion: '2.0',
    onVersionChange: (oldVersion, newVersion) => {
      // Optionally show banner again
      return true; // true = request consent again
    },
  }}
>

The library detects changes in the service list and can automatically invalidate previous consent, showing the banner even if the user had already chosen before.

The library includes 8 pre-designed banner variants:

<ConsentBanner
  variant="default"     // Standard bottom bar
  // variant="fullwidth"   // Full-width bar
  // variant="modal"       // Centered modal
  // variant="floating"    // Floating with shadow
  // variant="card"        // Compact card
  // variant="minimal"     // Text and buttons only
  // variant="corner"      // Bottom corner
  // variant="sidebar"     // Side panel

  position="bottom"     // 'top' | 'bottom' | 'bottom-left' | 'bottom-right'

  theme="light"         // 'light' | 'dark' | 'auto'
/>

All texts are customizable:

<ConsentBanner
  texts={{
    title: 'We use cookies',
    description: 'This site uses cookies to improve your experience.',
    acceptAll: 'Accept all',
    rejectAll: 'Reject all',
    customize: 'Customize',
    privacyPolicy: 'Privacy policy',
  }}
/>

The library includes translations for 10 languages:

<ConsentProvider
  config={{
    services: [googleAnalytics],
    language: 'en', // 'en' | 'es' | 'de' | 'fr' | 'pt' | 'it' | 'nl' | 'pl' | 'ja' | 'zh'
  }}
>

For languages not included, you can provide custom translations:

<ConsentProvider
  config={{
    services: [googleAnalytics],
    translations: {
      title: 'Käytämme evästeitä',
      description: 'Tämä sivusto käyttää evästeitä...',
      acceptAll: 'Hyväksy kaikki',
      rejectAll: 'Hylkää kaikki',
      // ... rest of texts
    },
  }}
>

Accessibility is full WCAG 2.2 AA: keyboard navigation, screen reader support, focus trap in modals, sufficient contrast in all themes.

Real use cases

E-commerce with multiple regions

<ConsentProvider
  config={{
    services: [
      googleAnalytics,
      googleAds,
      metaPixel,
      criteo,
      hotjar,
    ],
    geoDetection: { enabled: true, fallbackLaw: 'GDPR' },
    googleConsentMode: { enabled: true },
    granularConsent: true,
    auditLog: { enabled: true },
  }}
>

The online store automatically detects if the user is in Europe (strict GDPR), California (CCPA with opt-out), or rest of world. Google Consent Mode allows continuing to measure aggregate conversions even when users reject cookies.

B2B SaaS with compliance requirements

<ConsentProvider
  config={{
    services: [
      mixpanel,
      amplitude,
      segment,
      intercom,
      fullstory,
    ],
    granularConsent: true,
    serviceLevel: true,
    auditLog: {
      enabled: true,
      storage: 'server',
      endpoint: '/api/consent-logs',
    },
    complianceReport: { enabled: true },
  }}
>

The SaaS product allows granular control per service because its enterprise customers demand it. Audit logs are sent to the server for centralized storage. Compliance reports are generated to include in security documentation.

Personal blog with respectful analytics

<ConsentProvider
  config={{
    services: [plausible],
    banner: {
      variant: 'minimal',
      position: 'bottom-right',
    },
  }}
>

Plausible doesn’t use cookies and may not require consent in some jurisdictions, but showing a banner demonstrates transparency and builds trust with readers.

Testing and quality

The library has 323 tests with 100% coverage across all metrics:

MetricCoverage
Statements100%
Branches100%
Functions100%
Lines100%

Tests cover:

Verified compatibility:

The package is published on NPM as react-consent-shield. Source code is at github.com/686f6c61/react-consent-shield. The interactive demo is at react-consent-shield.onrender.com where you can test all configurations in real-time.