@storybrandhq/gtm-cookie-consent
v1.0.2
Published
Consent-gated first-party cookie helpers, synced to GTM consent state via Google Consent Mode.
Downloads
72
Maintainers
Readme
@storybrandhq/gtm-cookie-consent
Consent-gated first-party cookie helpers for React apps, synced to
Google Consent Mode state via a small GTM relay tag. Handles injecting the
GTM container, reading resolved (region-aware) consent state, and gating
document.cookie writes on that state — so no app has to hand-roll its own
consent check per cookie.
Division of responsibility:
- GTM — third-party vendor scripts (HubSpot, GA4, Hotjar, Meta Pixel, etc.), gated via GTM's own trigger/consent-requirement system, unrelated to this package.
- This package — first-party app cookies (referral codes, UTM capture, access-gate cookies) that your own app code sets directly, gated in code and reviewable in normal PRs.
One-time GTM setup
1. Add the Consent State variable template
- Templates → Search Gallery → search "Consent State" (Ayudante's template is the commonly used one) → Add to Workspace
- If your GTM account already has a built-in "Consent State" variable type under Variables → Configure, use that instead — check there first.
2. Create one variable instance per category you actually use
At minimum:
Var - Consent State - ad_storage
Var - Consent State - analytics_storage
Var - Consent State - personalization_storage
Var - Consent State - ad_personalizationEach variable instance needs its Consent Type field set to the matching category.
3. Create the relay tag
Tags → New → Custom HTML:
<script>
window.dispatchEvent(new CustomEvent('appConsentUpdate', {
detail: {
ad_storage: {{Var - Consent State - ad_storage}},
analytics_storage: {{Var - Consent State - analytics_storage}},
functionality_storage: {{Var - Consent State - functionality_storage}},
personalization_storage: {{Var - Consent State - personalization_storage}}
}
}));
</script>NOTE: The custom event MUST be named
appConsentUpdatefor this package to properly listen to it.
Firing triggers — needs both, not just one:
Initialization - All Pages
Trigger for you CMP (i.e. Enzuzo) on Consent UpdateInitialization: Covers a returning visitor who already has
consent resolved from a prior session. Without it, getConsent() returns
nothing until they interact with the banner again, even if they already
consented.
Consent Update: Covers a live change mid-session (visitor clicks Accept/Reject on the banner right now).
4. Testing in Preview mode
- Load the site in GTM Container's Preview mode
- Check the relay tag fires once on the Initialization event → check the
Variables tab for that event → confirm each Consent State variable
resolves to a real
true/false, not blank/undefined - Accept/reject the banner → confirm the relay tag fires again on CMP Consent Update, with updated values
- In the browser console:
should log a real object on both page load and banner interactionwindow.addEventListener('appConsentUpdate', e => console.log(e.detail))
App setup
1. Install
npm
npm install @storybrandhq/gtm-cookie-consentyarn
yarn add @storybrandhq/gtm-cookie-consent2. Initialize once, as early as possible
import { useEffect } from 'react';
import { init } from '@storybrandhq/gtm-cookie-consent';
function MyApp({ Component, pageProps }) {
useEffect(() => {
init({ gtmId: 'GTM-XXXXXXX' });
}, []);
return <Component {...pageProps} />;
}
export default MyApp;This injects the GTM container script and starts listening for the
appConsentUpdate relay event.
3. Keep the <noscript> fallback in your document <head>
init() only injects the <script> loader — the non-JS fallback iframe
still has to be static HTML, since it must render without JS ever running:
<body>
<noscript>
<iframe
src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
height="0"
width="0"
style={{ display: 'none', visibility: 'hidden' }}
/>
</noscript>
<Main />
</body>Remove any old GTM <script> snippet if one's still there — init() replaces it.
Usage
Setting a cookie (retries automatically once consent is granted)
import { setCookie } from '@storybrandhq/gtm-cookie-consent'
// The options are the same as the options for `js-cookie`, with some added ones
setCookie('cookie_name', params, {
category: 'analytics_storage',
days: 30,
domain: 'domain.com', // omit for host-only scoping (isolated per-subdomain cookies)
})If consent isn't granted yet, this queues the exact same call and
retries it automatically the next time consent changes. Pass retryOnConsent: false to opt out and treat a denial as
final instead.
The retry queue is in-memory only and does not survive page navigation. For a cookie whose value is not re-derivable (i.e. computed once from a one-time event, an API response, or anything not naturally present on the next page load), don't rely on the default retry; call with
retryOnConsent: falseand handle the "not set" case explicitly, or persist the source value yourself before attempting the cookie writing. We deliberately don't stash pending values insessionStoragefor non-essential cookies because writing tracking data to any client-side storage pre-consent raises the same compliance question as setting the cookie directly would.
Strictly necessary cookies (bypass consent entirely)
setCookie('cookie_name', '1', {
strictlyNecessary: true,
expires: new Date('Jan 1 2038'),
})Use only for cookies that are genuinely exempt under GDPR's ePrivacy strictly necessary category (access gates, session/security tokens). Do not use this flag as a way to skip the consent check for convenience.
Handling GTM variable race conditions
import { setCookieWhenReady } from '@storybrandhq/gtm-cookie-consent'
await setCookieWhenReady('cookie_name', param, {
category: 'analytics_storage',
days: 30,
})Retries with exponential backoff (~200ms → 3.2s, 5 attempts) until a real
appConsentUpdate event has been received, then resolves using that value.
Use this instead of setCookie if the call site can't guarantee it's running
after init()'s listener has had a chance to receive at least one event.
Reading a cookie
import { getCookie } from '@storybrandhq/gtm-cookie-consent'
const cookieValue = getCookie('cookie_name') // string | nullReading doesn't require its own consent check.
Gating rendered content (video embeds, forms, etc.)
import { useConsent } from '@storybrandhq/gtm-cookie-consent'
function ConsentGatedVideo({ vimeoId }) {
const hasConsent = useConsent('ad_storage')
if (!hasConsent) return <VideoPlaceholder />
return <iframe src={`https://player.vimeo.com/external/${vimeoId}?dnt=1`} />
}One-off check outside a component
import { getConsent } from '@storybrandhq/gtm-cookie-consent'
if (getConsent('analytics_storage')) {
// ...
}Recommended: lint guard
Add an ESLint rule banning raw document.cookie = outside this package's
own source, so a bypass gets caught in review rather than relying on
convention. StoryBrand has a shared ESlint config for this.
See @storybrandhq/eslint-config
