npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ourfires/nextjs-gtm

v2.2.0

Published

Google Tag Manager for Next.js with real Google Consent Mode v2: region-scoped defaults, server-resolved consent, no fake grants

Downloads

382

Readme

@ourfires/nextjs-gtm

Google Tag Manager for Next.js with real Google Consent Mode v2.

Region-scoped consent defaults resolved by Google from the visitor's IP, a consent decision resolved on your server before paint, and grants that are derived — never a stored consent nobody gave.

  • Next.js 16 / React 19, App Router
  • No runtime dependencies. ~7.5 KB gzipped for the client entry, under 0.4 KB for core
  • One inline script, no ordering race, no post-hydration branch
  • A consent store outside React, for SDKs initialised at module scope
  • Ships with an EEA/UK list that is actually complete, and a US posture that does not deny by default where no law asks it to

Install

npm install @ourfires/nextjs-gtm

Setup

1. Define the policy

// lib/consent.ts
import { defineConsentPolicy } from "@ourfires/nextjs-gtm/core";

// Everything is optional — this is the shipped default posture.
export const policy = defineConsentPolicy();

Import from /core, not the root: /core has no "use client" and no React, so a Server Component reading the policy does not drag the client tree into its module graph.

2. Render it

// app/layout.tsx
import { Suspense } from "react";
import { ConsentScript, resolveConsent } from "@ourfires/nextjs-gtm/server";
import { ConsentProvider, ConsentBanner } from "@ourfires/nextjs-gtm";
import { policy } from "@/lib/consent";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/* First thing in <head>. Reads no request data, so it stays static. */}
        <ConsentScript gtmId="GTM-XXXXXX" policy={policy} />
      </head>
      <body>
        <Suspense fallback={null}>
          <ConsentUI>{children}</ConsentUI>
        </Suspense>
      </body>
    </html>
  );
}

async function ConsentUI({ children }: { children: React.ReactNode }) {
  const decision = await resolveConsent(policy);
  return (
    <ConsentProvider policy={policy} decision={decision}>
      {children}
      <ConsentBanner config={{ privacyPolicyUrl: "/privacy", theme: "dark" }} />
    </ConsentProvider>
  );
}

resolveConsent() reads cookies() and headers(), so the component calling it renders dynamically. The <Suspense> boundary is what keeps that from forcing the whole route.

The provider only covers what it wraps. {children} goes inside it above, because anything in your pages that calls useConsent() or renders <ConsentGate> needs it as an ancestor — outside it, useConsent() throws, and the error tells you to put the provider in your root layout, which you did.

If nothing in your pages reads consent, you can leave {children} outside and let the provider wrap the banner alone:

      <body>
        {children}
        <Suspense fallback={null}>
          <ConsentUI />
        </Suspense>
      </body>

If anything reads consent outside React, this shape is not enough — see Consent outside React for the one it needs.

3. Request interception — only if you are not on Vercel or Cloudflare

On Vercel and Cloudflare you do not need this at all: resolveConsent() reads x-vercel-ip-country / x-vercel-ip-country-region and cf-ipcountry / cf-region-code straight off the request.

Everywhere else — Netlify, Fly, Render, self-hosted Node, anything behind plain nginx — you need it, and you have to pass resolveRegion. Nothing else on the request says where the visitor is, and a bare consentProxy() reads the same two header pairs resolveConsent() already tried, so on those hosts it changes nothing.

// proxy.ts  (Next.js 16 — use middleware.ts on 15, or if you need the edge runtime)
import { consentProxy } from "@ourfires/nextjs-gtm/server";

export const proxy = consentProxy({
  // ISO 3166-2 — "IT", "US-CA" — or null when you cannot tell.
  resolveRegion: (request) => request.headers.get("x-geo-country"),
});

export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"] };

Without a region, every visitor is treated as being in a prompt region and starts with everything denied. That is the fail-safe working, not a bug — but it means the banner shows to the whole world, so it is worth knowing which case you are in. decision.warnings says which.

In local development there is no geo header either. next dev on localhost has no x-vercel-ip-country, so you get exactly that: banner on first load, all grants false. Adding proxy.ts does not change it — with no resolveRegion it reads the same missing headers. To test a specific region, say so:

export const proxy = consentProxy({
  resolveRegion: (request) =>
    process.env.NODE_ENV === "development"
      ? "IT"
      : request.headers.get("x-geo-country"),
});

Google's side is unaffected: <ConsentScript> carries no request data and gtm.js geolocates by IP, so on localhost the two authorities legitimately disagree.

4. Set the consent checks in your GTM container — required

Not optional, and not something this package can do for you. Until you do it, everything above is wired up correctly and your non-Google tags still fire regardless of consent.

The setting differs by tag, and getting it backwards breaks things in both directions.

Google tags — GA4, Google Ads, Floodlight: leave them alone. They read the consent state this package emits through their own built-in checks and change their behaviour accordingly. Set Advanced Settings → Consent Settings → "No additional consent required". Google is explicit that doing both does not work: "If you have implemented consent mode and use additional consent checks at the same time, they won't work properly."

Everything else — Meta, LinkedIn, TikTok, Custom HTML, any third-party pixel: gate them. These have no built-in checks, so open each one and set "Require additional consent for tag to fire", listing the consent types it needs:

| category | consent types to require | |---|---| | marketing | ad_storage, ad_user_data, ad_personalization | | analytics | analytics_storage | | preferences | functionality_storage, personalization_storage |

The reason it is mandatory for those: a consent type gtm.js never had set reads as granted, not denied — isConsentGranted returns true when a type is 'granted' or not set at all. A third-party tag with no consent check has nothing to block it, whatever this package emits, and the React-side ConsentGate only covers what is outside the container.

One consequence worth knowing before you ship: a gated tag is evaluated when its trigger fires, and not again. "Even if consent is later granted, tags don't fire unless consent is granted when they were first triggered." So on a visitor's first session, an All Pages tag gated on marketing is evaluated before they touch the banner, is blocked, and does not come back when they accept — that page view is gone. Google tags are unaffected: they fire either way and adapt.

For the third-party tags where that matters, add a Custom Event trigger on consent_updated alongside the existing one. This package pushes that event when the visitor records a choice, carrying grants and method:

{ event: "consent_updated", grants: { analytics, marketing, preferences }, method: "accept-all" }

It fires only on an actual save, never on the replay of a stored record, so a returning visitor cannot double-fire anything: their All Pages trigger already finds the consent in place and no consent_updated is pushed. The tag's own consent checks still apply, so a save that rejects cannot let it through. Rename it or turn it off with google.consentUpdatedEvent.

Do not add this to a Google tag. Google's guidance for tags with built-in consent checks is to fire them on business criteria only, not on a CMP's consent event.


Reading consent in your app

"use client";
import { useConsent, ConsentGate } from "@ourfires/nextjs-gtm";

function Example() {
  const { grants, source, status, promptRequired, reopen } = useConsent();

  return (
    <>
      <ConsentGate category="marketing" whenUnset="render">
        <MetaPixel />
      </ConsentGate>
      <button onClick={reopen}>Cookie settings</button>
    </>
  );
}

source is the part worth reading before you treat grants as a decision:

| source | means | |---|---| | user | a human chose, on this site | | policy-default | the site decided for them, because their region does not require asking | | gpc | their browser sent Global Privacy Control |

All three can produce marketing: true. They are not the same thing, and only the first is consent.

ConsentGate has no default for whenUnset on purpose, and the choice matters more than it looks. It keys on status, not on grants:

  • render goes straight to grants. The embed follows the same policy default as the container's own tags.
  • hide renders the fallback until the visitor has personally stored a choice.

They differ for exactly one visitor: the one with no stored record whom policy already grants. By default that is everyone outside a prompt region — and they never see a banner, so status stays unset forever and hide hides the child on every visit, permanently, while the container's marketing tags fire for that same person. Inside a deny region the two behave identically.

So: render unless the embed must never load without an explicit click, and if you pick hide, know that outside prompt regions the click never comes.

fallback renders in place of hidden children; it defaults to null.

What useConsent() gives you

Eleven fields, typed as ConsentContextValue (exported from the root entry).

| field | type | means | |---|---|---| | grants | { analytics, marketing, preferences } | what may happen right now | | source | "user" \| "policy-default" \| "gpc" | who decided — see the table above | | status | "recorded" \| "unset" | whether a stored decision exists under the current policy.version | | promptRequired | boolean | whether the banner should show | | geo | { code: string \| null, source: "header" \| "unknown" } | the region we resolved, null when no signal arrived | | warnings | string[] | non-fatal problems — see When it does not work | | policy | ConsentPolicy | the policy this decision was made under | | acceptAll() | | grant everything | | rejectAll() | | grant nothing | | update(partial) | | set some categories, merged into the current grants | | reopen() | | show the banner again |

Two of these are easy to misread:

unset is not denied. Outside a prompt region a visitor is unset and granted by policy — permanently, because no banner ever appears to record anything. A record stored under an older policy.version also reads unset.

promptRequired is not !status. It is false outside promptIn, false once a record exists, and true when no geo signal arrived at all — an unknown location fails safe to asking.

Changing consent

acceptAll(), rejectAll() and update() each write the cookie, push a real ['consent','update',{…}] command to the data layer on the current page, and move the decision to source: "user", status: "recorded", promptRequired: false.

reopen() does none of that. It only flips promptRequired back to true locally — the stored record is untouched until the visitor chooses again. That is what a "cookie settings" link in your footer should call.

This is the whole write API, so it is also everything you need to replace the shipped banner:

"use client";
import { useConsent } from "@ourfires/nextjs-gtm";

export function MyBanner() {
  const { promptRequired, grants, acceptAll, rejectAll, update } = useConsent();
  if (!promptRequired) return null;

  return (
    <div>
      <button onClick={acceptAll}>Accept all</button>
      <button onClick={rejectAll}>Reject all</button>
      <button onClick={() => update({ analytics: true })}>Analytics only</button>
    </div>
  );
}

Gate on promptRequired, not on status — otherwise reopen() cannot bring your banner back. There is no necessary category to set: it is not optional, so it is not state.


Consent outside React

The things that most need to obey consent are often not React components and not container tags: an analytics, error-tracking or chat SDK initialised at module scope. It runs before anything mounts, so it cannot read a context — and it cannot work the decision out for itself either, because the cookie does not contain the region. The region arrives on request headers and is resolved on the server.

getConsentStore() is that decision, outside React:

// lib/analytics.ts — module scope, no React
import { getConsentStore } from "@ourfires/nextjs-gtm";
import { policy } from "@/lib/consent";
import posthog from "posthog-js";

const consent = getConsentStore(policy);

function apply(analytics: boolean) {
  if (analytics && !posthog.__loaded) posthog.init(KEY, { api_host: HOST });
  else if (!analytics) posthog.opt_out_capturing();
}

apply(consent.get().grants.analytics);          // now
consent.subscribe((d) => apply(d.grants.analytics)); // and on every change

get() never throws — on the server it returns the fail-safe. subscribe() returns an unsubscribe function. acceptAll(), rejectAll(), update() and reopen() are the same operations the hook exposes, and go through the same store, so a save from the banner reaches these subscribers. There is no second copy of the state: ConsentProvider is a view over this.

Render <ConsentSeed>, or get() lies to you

The store cannot know the region on its own. Give it the server's answer, in <head>, before any of your own JavaScript runs:

// app/layout.tsx
import { ConsentScript, ConsentSeed, resolveConsent } from "@ourfires/nextjs-gtm/server";
import { ConsentProvider, ConsentBanner } from "@ourfires/nextjs-gtm";
import { policy } from "@/lib/consent";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const decision = await resolveConsent(policy);

  return (
    <html lang="en">
      <head>
        <ConsentScript gtmId="GTM-XXXXXX" policy={policy} />
        <ConsentSeed policy={policy} decision={decision} />
      </head>
      <body>
        <ConsentProvider policy={policy} decision={decision}>
          {children}
          <ConsentBanner config={{ privacyPolicyUrl: "/privacy" }} />
        </ConsentProvider>
      </body>
    </html>
  );
}

<ConsentSeed> carries request data, which is exactly why it is a separate tag from <ConsentScript> — that one stays byte-identical for every visitor and cacheable, and this one does not have to be. Awaiting resolveConsent() in the layout does mean the route renders dynamically. That is the cost of the store, and it is the trade: without the seed, the first get() returns the fail-safe — nothing granted — and for an SDK that reads once at module scope, "corrected later" means never. warnings says so, and the correction only ever runs denied → granted, never the reverse.

If you do not need the store, keep the <Suspense> shape in step 2 instead and let the shell prerender.

Cross-tab is not supported. Cookies do not raise storage events, so a choice made in one tab does not reach a store in another until that tab reloads.


What actually gates your tags

Two independent authorities, ANDed, so a wrong answer from either one fails safe:

  1. Google's geo. <ConsentScript> emits region-scoped gtag('consent','default') commands. Google resolves the visitor's region from their IP, with no input from you. This is what gates tags inside the container.
  2. Your geo. resolveConsent() reads the CDN's headers. This decides whether the banner appears and what useConsent() reports.

Mis-locate an Italian visitor as American and the banner is skipped — but Google still matches them by IP and ad_storage stays denied. Mis-locate an American as Italian and they get a banner they did not need. Both directions are survivable; a single authority is not.

The third authority, which lives in the GTM UI

Neither of the above can enforce anything on its own. Enforcement is per-tag, in the container — see step 4. Nothing here can set those checks, test them, or version them.

Container-scoped defaults set in the GTM UI also take precedence over the on-page defaults this package emits, for ad_storage, analytics_storage, ad_user_data and ad_personalization. If your defaults look ignored, check there first.


When it does not work

Every decision carries a warnings array. Nothing prints it for you:

const decision = await resolveConsent(policy);
if (process.env.NODE_ENV !== "production") decision.warnings.forEach((w) => console.warn(w));

That is a Server Component, so those land in your terminal. useConsent().warnings is the same array on the client. decision.geo.source === "unknown" is the no-geo case.

| symptom | cause | |---|---| | a third-party tag fires whatever the consent | its consent checks are not set in the container — step 4. A tag with no check has nothing blocking it, whatever this package emits | | a third-party tag never fires | the checks are set and the category is denied. Read source and grants: a denyIn region, a GPC signal and a user reject-all all produce this, and all three are correct | | a third-party tag missed a visitor's first session | it was evaluated when its trigger fired, before the banner was touched, and a later grant does not re-fire it. Expected — see step 4 | | a Google tag behaves erratically | it has additional consent checks set as well as its built-in ones. Google's guidance is that the two together "won't work properly" — set it to "No additional consent required" | | your defaults look ignored | container-scoped defaults in the GTM UI take precedence — see above | | the banner shows to everyone | no geo signal reached resolveConsent(). Normal on localhost; off Vercel and Cloudflare it means resolveRegion is missing. warnings says so | | a region is denied and never asks | it is in denyIn but not promptIn. Only possible if you passed promptIn yourself; warnings names the region | | everyone is re-prompted at once | policy.version was bumped, which invalidates every stored record | | useConsent() throws | it was called outside <ConsentProvider> — check {children} is inside it | | a module-scope SDK never starts | getConsentStore().get() returned the fail-safe because <ConsentSeed> is not rendered. warnings says so |


Regions

The shipped defaults, all overridable:

| list | what it is | default | |---|---|---| | denyIn | everything denied, region-scoped | EEA + UK (31) + EU territories with their own ISO code (7) + Crown Dependencies and Gibraltar (4) + KR, TH, TR, CA-QC | | denyAdsIn | ads denied, analytics granted | CH, BR, SA | | promptIn | show the banner | derived from denyIn ∪ denyAdsIn unless you pass it | | honorGPC | categories a GPC signal switches off | ["marketing"] | | otherwise | everyone else | all granted |

import { defineConsentPolicy, DEFAULT_DENY_IN, US_HEALTH_OPT_IN } from "@ourfires/nextjs-gtm/core";

export const policy = defineConsentPolicy({
  denyIn: [...DEFAULT_DENY_IN, ...US_HEALTH_OPT_IN], // health/wellness sites
  otherwise: { marketing: false },                    // cautious elsewhere
});

promptIn follows on its own here — widening denyIn widens it, so the states you just added get a banner rather than a permanent denial with no way out. Pass promptIn explicitly only when you want a region denied and asked in different places; it is then taken as written, and any deny region it fails to cover is reported in development and in decision.warnings.

Some deliberate choices, because the opposite is common and wrong:

  • US states are not denied by default. They are opt-out regimes — a banner is not the statutory ask, and denying there would suppress measurement across a large share of US traffic for no legal benefit. What they require is honouring GPC, which honorGPC does. The live enforcement risk is ignoring the signal, not the missing banner.
  • GPC denies marketing, not analytics. These are sale/sharing statutes. Denying first-party measurement on a GPC signal is a common over-correction nobody asked for.
  • Federal Canada is not denied; Quebec is. PIPEDA accepts implied consent for non-sensitive data. Law 25 s.8.1 requires profiling technology to be off by default. So the entry is CA-QC, not CA.
  • Switzerland and Brazil are split, not flattened. Both regulators accept an interest-balancing basis for audience measurement and require opt-in for personalised advertising.
  • "EU", "EEA" and "UK" are not region codes. gtm.js compares against exactly two strings, the country and the subdivision. Groups match nothing. Use "GB".

src/core/regions.ts carries the reasoning and flags what is contested. Read it before editing it — several entries are deliberate omissions, not oversights.

The rest of the policy

| option | default | notes | |---|---|---| | version | 1 | A record is honoured only while record.v === policy.version. Bumping invalidates every stored choice and re-prompts — the only way to reach visitors who already decided after you change the region lists | | cookie.name | "consent" | what to look for in devtools | | cookie.maxAgeDays | 365 | written as Max-Age | | cookie.sameSite | "lax" | | | cookie.secure | true | | | cookie.domain | unset | set it to share consent across subdomains | | cookie.partitioned | unset | CHIPS; needs secure and sameSite: "none" to mean anything | | google.dataLayerName | "dataLayer" | renaming it also appends &l= to the container URL, as it must | | google.adsDataRedaction | true | redacts ad click identifiers while ad_storage is denied | | google.urlPassthrough | true | passes click and session ids through URL parameters while storage is denied. Check your redirects preserve gclid, _gl and friends | | google.waitForUpdate | unset | milliseconds to hold tags waiting for a consent update. Leave it off: the stored choice is replayed synchronously in the same script, so there is nothing late to wait for, and setting it delays tags on every visit that has no stored choice | | google.consentUpdatedEvent | "consent_updated" | data layer event pushed when the visitor records a choice, so a third-party tag whose trigger already passed gets a second chance — see step 4. A string renames it, false pushes nothing | | legacyCookieName | "user_consent" | the v1 cookie to migrate from. null to ignore it |

The cookie is deliberately readable by JavaScript — the head script reads it in the browser to replay the choice before the container loads, which is what keeps that script free of request data and therefore static. See Advanced mode, and what it costs.


Banner options

<ConsentBanner config={…} /> takes six fields, typed as BannerConfig.

| option | default | | |---|---|---| | privacyPolicyUrl | unset | renders a link when set | | showPreferencesDefault | false | open straight into the per-category view | | position | "bottom" | or "top" | | theme | "light" | or "dark" | | translations | English | Partial<Translations>, merged over the defaults | | colors | see below | { light?: ThemeColors, dark?: ThemeColors } |

The banner renders with inline styles and ships no CSS, so colors is the styling API — there is no class hook to target. Eight slots per theme: background, text, border, accent, secondaryButton, secondaryButtonText, toggleBoxBackground, toggleSwitchBackground. Only the theme in use is read: colors.dark does nothing while theme is "light".

translations has 17 keys: title, description, acceptAll, rejectAll, customize, privacyPolicy, preferencesTitle, savePreferences, back, and Label/Description pairs for necessary, analytics, marketing and preferences.

<ConsentBanner
  config={{
    theme: "dark",
    privacyPolicyUrl: "/privacy",
    translations: { acceptAll: "Accetta tutti", rejectAll: "Rifiuta tutti" },
    colors: { dark: { accent: "#e11d48" } },
  }}
/>

BannerConfig, Translations and ThemeColors are exported from the root entry for typing overrides. If you need more than this, build your own — the write API is three functions.


Advanced mode, and what it costs

This package implements Google's advanced consent mode: the container loads on every page, and tags run in cookieless mode until consent arrives.

That is a decision with a real edge. In advanced mode gtm.js loads and GA4 pings leave an EEA browser before the visitor touches the banner. The EDPB's Art. 5(3) technical-scope guidance and the ICO's reading of PECR reg. 6 both bring that in scope, and Google explicitly leaves the assessment to you. Basic mode — not loading the container until consent — avoids it and forfeits GA4 behavioural modelling entirely, dropping Ads conversion modelling to the general model. Note also that Ads conversion modelling needs roughly 700 ad clicks per 7 days per country and domain before it does anything at all; below that, advanced mode buys you the exposure and none of the recovery.

Other limits worth stating plainly:

  • Revoking consent cannot unload gtm.js, cannot delete cookies already written, and cannot un-fire tags that already ran. The first pageview is already gone.
  • The consent cookie is readable by JavaScript, because the head script reads it in the browser to replay the choice before the container loads — which is what keeps that script free of request data, and therefore static and cacheable. Anything already running on the page could forge it. What forging it achieves is making the visitor's own browser assert a consent they did not give; it grants access to nothing.
  • No TC string. Programmatic display and ad exchanges expecting IAB TCF signalling need a certified CMP; this is not one.
  • The <noscript> container iframe is deliberately not rendered. It carries no consent signalling of any kind.

Migrating from v1

Every export changed. There is no compatibility layer.

| v1 | v2 | |---|---| | new ConsentManager() | defineConsentPolicy() from /core | | <GDPRGoogleTagManager>, <GTMWithConsent> | <ConsentScript> in <head> + <ConsentProvider> | | useConsent(consentManager) | useConsent() — reads context, throws outside the provider | | consent.analytics, hasAnalyticsConsent() | grants.analytics | | checkGeoConsent(), the geo-needs-consent cookie | gone — geo is a request header now | | geoAware, waitForConsent props | gone — one decision is resolved upstream | | {event:'consent_default'} dataLayer pushes | real ['consent','default',{…}] commands |

Your visitors are not re-prompted. The v1 user_consent cookie is read and honoured verbatim, including a stored reject-all, for as long as policy.version stays at 1. Bump the version to invalidate every stored record and ask again.

If your GTM container has triggers or variables built on the consent_default / consent_update custom events, delete them. They never drove Consent Mode — that is the bug this version fixes — but they will stop arriving.

Two things v1 got wrong that are worth knowing about, because they may have been masking each other:

  • The documented middleware used request.geo, removed in Next 15. On Next 15/16 it was always undefined, so isRegulatedRegion() hit its fail-safe and returned true for everyone. Geo-aware mode treated the whole world as regulated.
  • Outside a regulated region, nothing ever raised consent above the denied default, so anything gated on marketing never fired — in the US or anywhere else.

The first bug hid the second. Fixing only the middleware would have exposed it.


Entry points

| import | contains | environment | |---|---|---| | @ourfires/nextjs-gtm | ConsentProvider, useConsent, ConsentGate, ConsentBanner, getConsentStore | client ("use client") | | @ourfires/nextjs-gtm/server | ConsentScript, ConsentSeed, resolveConsent, consentProxy | server | | @ourfires/nextjs-gtm/core | defineConsentPolicy, decide, region lists, types | anywhere |

License

MIT