> ## Documentation Index
> Fetch the complete documentation index at: https://quickbutik.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Consent and analytics

> The cookie banner, Google Consent Mode, the merchant's GA4, GTM and Meta pixel, commerce events and the purchase. On by default, nothing to wire up.

**On by default.** A storefront built with the kit shows a cookie banner, remembers the shopper's decision, loads the merchant's own GA4, GTM and Meta pixel (configured in the Quickbutik admin) once the shopper agrees, reports GA4-shaped commerce events, fires the purchase on the thank-you page and forwards the decision to the hosted checkout.

<CodeGroup>
  ```tsx React theme={null}
  <ShopkitProvider
    config={{ publishableKey }}
    consent={{ lang: "sv", privacyPolicyUrl: "/integritetspolicy" }}
  >
    <App />
    <footer><ConsentSettingsButton /></footer>
  </ShopkitProvider>
  ```

  ```html Script tag theme={null}
  <script defer
    src="https://cdn.jsdelivr.net/npm/@quickbutik/kit@1.8.0/dist/quickbutik-kit.global.js"
    data-publishable-key="qb_pk_…"
    data-consent-lang="sv"
    data-privacy-policy-url="/integritetspolicy"></script>

  <footer><qb-consent-settings lang="sv"></qb-consent-settings></footer>
  ```

  ```ts Web components (bundler) theme={null}
  configure({
    publishableKey,
    consent: { lang: "sv", privacyPolicyUrl: "/integritetspolicy" },
  })
  ```
</CodeGroup>

That is the whole integration. The `consent` prop only carries options: the banner's language and privacy link. Leave it out and you still get the banner, in the language of `<html lang>`.

## What happens by default

* **A banner on the first visit.** Styled, accessible, in English, Swedish, Danish, Norwegian or Finnish, with "accept all" and "only necessary" equally prominent and a settings panel per category.
* **Nothing optional loads before the shopper agrees.** No script from Google or Meta is requested while the shopper is undecided or has said no.
* **Google after `analytics`.** gtag.js (or the GTM container) is injected once `analytics` is granted, with Consent Mode's denied `default` and then the `update` pushed ahead of `js` and `config`.
* **Meta after `marketing`.** The pixel is loaded only once `marketing` is granted, and is told `fbq("consent", "grant")` before `init`.
* **The merchant's own ids.** GA4, GTM and Meta ids are read from the shop (`shop.tracking`), the same ids the hosted checkout loads, so both report into the same properties. A shop with none configured gets the banner and sends nothing.
* **Events and the purchase are automatic.** Product views, searches, cart changes and the redirect-mode purchase are reported for you. See [Events](#events).
* **The checkout is told.** Every handoff to the hosted checkout, including one started on your server, carries the decision. See [Forwarding consent to the hosted checkout](#forwarding-consent-to-the-hosted-checkout).
* **Withdrawal stops everything.** Changing your mind in the settings updates Consent Mode, revokes Meta's consent, and no destination receives anything more.

## Next.js App Router

The provider alone works in Next.js. Three small additions make a server-rendered shop exact: the banner never flashes for a returning shopper, a checkout started in a server action carries the GA session, and the thank-you page fires the purchase from the server's snapshot.

<Steps>
  <Step title="Give the server client getAll()">
    The server client already reads cookies for the cart. Add `getAll()` so `checkout.start()` can also find the GA cookie (`_ga_<property>`, whose name is not known in advance).

    ```ts lib/shopkit.ts theme={null}
    import { createShopkitClient } from "@quickbutik/kit"
    import { cookies } from "next/headers"

    export async function shopkit() {
      const jar = await cookies()
      return createShopkitClient({
        ...config,
        cookies: {
          get: (name) => jar.get(name)?.value ?? null,
          getAll: () => jar.getAll(),
          set: (name, value, attrs) => jar.set(name, value, attrs),
        },
      })
    }
    ```

    In a Server Component, `set` is dropped (Next.js does not allow cookie writes there), which is fine for reads.
  </Step>

  <Step title="Read the decision in the layout">
    `consent.read()` returns the shopper's decision from the request, using the revision in your config. Hand it to the provider so the first HTML byte already knows whether to show the banner.

    ```tsx app/layout.tsx theme={null}
    const client = await shopkit()
    const consent = await client.consent.read()

    return (
      <html lang="sv">
        <body>
          <Providers consent={consent}>
            {children}
            <footer><ConsentSettingsButton /></footer>
          </Providers>
        </body>
      </html>
    )
    ```

    ```tsx app/providers.tsx theme={null}
    "use client"

    export function Providers({ consent, children }) {
      return (
        <ShopkitProvider
          config={config}
          consent={{ initialState: consent, lang: "sv", privacyPolicyUrl: "/integritetspolicy" }}
        >
          {children}
        </ShopkitProvider>
      )
    }
    ```

    Pass `lang` explicitly when the banner renders on the server. Without it the server renders English and the browser switches to `<html lang>` after hydration.
  </Step>

  <Step title="Start the checkout as before">
    No consent code in the server action. `checkout.start()` appends the decision itself.

    ```ts app/cart/actions.ts theme={null}
    "use server"

    export async function startCheckout() {
      const client = await shopkit()
      const { url } = await client.checkout.start({ successUrl, backUrl })
      redirect(url) // carries consentCategories, plus gaClientId and gaSessionId when analytics is granted
    }
    ```
  </Step>

  <Step title="Hand the thank-you page its server snapshot">
    ```tsx app/success/[orderNumber]/order-confirmation.tsx theme={null}
    "use client"

    const { status, orderNumber } = useOrderConfirmation(sessionId, { initialConfirmation })
    ```

    When the server already read the order as completed, the hook does not poll. It forgets the bought cart and fires the purchase from that snapshot, once. See [The purchase](#the-purchase).
  </Step>
</Steps>

The policy revision lives in the config (`consent: { revision: 2 }`), so the server read, the browser provider and the checkout handoff always agree on it.

## Turning things off

| | React | Elements / `configure()` | Script tag |
| - | - | - | - |
| Everything: no store, banner or analytics, nothing on checkout URLs | `consent: false` in the client config | `configure({ consent: false })`, `<qb-shop consent="false">` | `data-consent="false"` |
| Analytics only (keep the banner) | `consent={{ analytics: false }}` | `configure({ analytics: false })`, `<qb-shop analytics="false">` | `data-analytics="false"` |
| The default banner (render your own) | `consent={{ banner: false }}`, or your own `<ConsentBanner>` | `configure({ consent: { banner: false } })`, or your own `<qb-consent-banner>` | `data-consent-banner="false"` |
| The banner's stylesheet | `consent={{ unstyled: true }}`, `<ConsentBanner unstyled>` | `configure({ consent: { unstyled: true } })`, `<qb-consent-banner unstyled>` | `data-consent-unstyled` |

**Opt out in the config, not on the provider.** `consent: false` in the config passed to `createShopkitClient` or `<ShopkitProvider config>` is read by the browser and by the server's `checkout.start()` alike, and restores the behaviour from before consent existed, exactly.

```ts theme={null}
const config = { publishableKey, consent: false }
```

<Note>
  `<ShopkitProvider consent={false}>` still works for that React tree, but is deprecated and warns: a checkout started on the server cannot see a prop, so it would still forward the decision.
</Note>

A banner you place yourself always replaces the default one, and providers nested inside each other share one decision. The kit never shows two banners.

## Theming the banner

The default banner is a fixed card bottom-right (a sheet on phones), light or dark with `prefers-color-scheme`. Its stylesheet sits in `@layer qb-consent`, so **any rule of yours wins** without `!important`. Map its custom properties to your own tokens:

```css theme={null}
:root {
  --qb-consent-bg: var(--surface);
  --qb-consent-fg: var(--ink);
  --qb-consent-accent: var(--brand);
  --qb-consent-accent-fg: #fff;
  --qb-consent-radius: 4px;
}
```

A colour you set applies in both schemes; set it inside your own media query for a separate dark value.

<Accordion title="All custom properties and class names">
  Custom properties: `--qb-consent-bg`, `-fg`, `-muted`, `-border`, `-accent`, `-accent-fg`, `-radius`, `-button-radius`, `-font`, `-font-size`, `-padding`, `-gap`, `-offset`, `-max-width`, `-shadow`, `-focus`, `-z-index`.

  Class names, for anything the properties do not cover: `qb-consent`, `qb-consent__body`, `__title`, `__description`, `__policy`, `__categories`, `__category`, `__category-name`, `__category-description`, `__actions`, `__button`, `__button--accept`, `--reject`, `--settings`, `--save`.

  The stylesheet is exported as `CONSENT_STYLES`, with `injectConsentStyles()` for pages that render their own markup but want the defaults.
</Accordion>

## What is gated

| Category | Covers | Gate |
| - | - | - |
| `necessary` | The cart and checkout ids the kit stores, and the decision itself | None: strictly necessary, never asked |
| `analytics` | GA4, GTM and Google Ads measurement | gtag.js / GTM not loaded; Consent Mode `analytics_storage` |
| `marketing` | The Meta pixel; Google's ad signals | Pixel not loaded; `fbq("consent")`; Consent Mode `ad_storage`, `ad_user_data`, `ad_personalization` |

* **Undecided is denied.** Until the shopper chooses, nothing optional is loaded or sent, and the hosted checkout is told so.
* **Withdrawal is one call.** `reset()` forgets the decision, shows the banner again and stops every destination. Scripts already on the page cannot be unloaded; they are simply not spoken to again.
* **A policy change asks again.** Bump `revision` and every stored decision reads as undecided.

<Accordion title="Loading Google before a decision (advanced Consent Mode)">
  `loadBeforeConsent: true` loads gtag.js or the GTM container before the shopper decides, under a Consent Mode default of everything denied. Google's tags then send cookieless pings and model conversions.

  ```tsx theme={null}
  <ShopkitProvider consent={{ analytics: { loadBeforeConsent: true } }}>
  ```

  ```ts theme={null}
  configure({ analytics: { loadBeforeConsent: true } })
  ```

  ```html theme={null}
  <script … data-analytics-load-before-consent></script>
  ```

  A GTM container loaded this way runs **all** of its tags, each gated only by how it reads Consent Mode. Decide this with whoever owns the container.
</Accordion>

## The consent store

The decision lives in a `qb_consent` cookie (JSON, 180 days, `SameSite=Lax`, `Secure` on https, host-only), so a **server can read it**:

```ts theme={null}
// With a server client: its own cookie accessor and configured revision
const state = await shopkit.consent.read()

// With nothing but a Cookie header
import { readConsentCookie } from "@quickbutik/kit"
const state = readConsentCookie(request.headers.get("cookie"), { revision: 1 })
```

`<ShopkitProvider>` and the elements create the store for you. It is public for pages that drive consent themselves:

```ts theme={null}
import { ConsentStore } from "@quickbutik/kit"

const consent = new ConsentStore({ revision: 1 })
consent.getSnapshot()        // { status, analytics, marketing, decidedAt, revision, open }
consent.subscribe(() => {})
consent.allows("marketing")
consent.acceptAll(); consent.rejectAll(); consent.save({ analytics: true, marketing: false })
consent.reset()
consent.openSettings()
```

In React, `useConsent()` returns the same state and actions. Every decision is also dispatched on `document` as a `qb:consent` event.

| Option | Default | |
| - | - | - |
| `cookieName` | `qb_consent` | |
| `maxAgeDays` | `180` | |
| `revision` | `1` | Bump to re-ask everyone |
| `domain` | Host-only | `.minbutik.se` to share across subdomains |
| `sameSite` / `secure` | `lax` / https-only | |
| `persist` | `true` | `false` when a third-party consent platform owns the decision |
| `initialState` | Read the cookie | The server's `shopkit.consent.read()` |

These options are accepted by `<ShopkitProvider consent={{ … }}>`, `<ConsentProvider>` and `configure({ consent: { … } })`. Set `revision` and `cookieName` once in the client config (`consent: { revision: 2 }`), where the server read and the checkout handoff see them too.

### With a third-party consent platform

If Cookiebot, CookieYes or similar already asks the shopper, hide the kit's banner and feed it the platform's answer:

```tsx theme={null}
<ShopkitProvider config={config} consent={{ banner: false, persist: false }}>
```

```ts theme={null}
const { save } = useConsent()
// in the platform's callback:
save({ analytics: cmp.statistics, marketing: cmp.marketing })
```

With `persist: false` the kit writes no cookie, so a checkout started **on the server** cannot see the decision and forwards the shopper as denied. Start the checkout in the browser (`useCheckout()`), which reads the store, or read the platform's own cookie on the server and pass its answer to `appendCheckoutHandoffParams`, which replaces the forwarded decision.

## Building your own banner

<Tabs>
  <Tab title="React">
    ```tsx theme={null}
    import { ConsentBanner, ConsentGate, ConsentSettingsButton } from "@quickbutik/kit/react"

    <ConsentBanner>
      {({ visible, open, draft, setDraft, acceptAll, rejectAll, save, openSettings, labels }) =>
        visible && (
          <aside className="cookie-bar">
            <p>{labels.description}</p>
            {open && (
              <label>
                <input
                  type="checkbox"
                  checked={draft.marketing}
                  onChange={(e) => setDraft({ marketing: e.target.checked })}
                />{" "}
                Marknadsföring
              </label>
            )}
            <button onClick={rejectAll}>Endast nödvändiga</button>
            <button onClick={open ? save : openSettings}>{open ? "Spara" : "Inställningar"}</button>
            <button onClick={acceptAll}>Godkänn alla</button>
          </aside>
        )}
    </ConsentBanner>

    <ConsentSettingsButton>Ändra cookieval</ConsentSettingsButton>

    <ConsentGate category="marketing" fallback={<p>Tillåt marknadsföring för att se videon.</p>}>
      <iframe src="https://www.youtube.com/embed/…" />
    </ConsentGate>
    ```

    Your `<ConsentBanner>` anywhere under the provider replaces the default one. The same render function can also go straight on the provider: `consent={{ banner: (state) => … }}`.

    `<ConsentSettingsButton>` renders a `<button type="button">` labelled in the provider's language ("Cookie-inställningar" in Swedish) that reopens the settings panel. It renders nothing when consent is off, so a shared footer is safe.
  </Tab>

  <Tab title="Web components">
    ```html theme={null}
    <qb-consent-banner>
      <p data-qb-text="consent.labels.description"></p>
      <fieldset data-qb-show="consent.open">
        <label><input type="checkbox" data-qb-consent="analytics"> Analys</label>
        <label><input type="checkbox" data-qb-consent="marketing"> Marknadsföring</label>
      </fieldset>
      <button data-qb-action="reject-all">Endast nödvändiga</button>
      <button data-qb-action="open-settings" data-qb-hide="consent.open">Inställningar</button>
      <button data-qb-action="save" data-qb-show="consent.open">Spara</button>
      <button data-qb-action="accept-all">Godkänn alla</button>
    </qb-consent-banner>

    <qb-consent-settings lang="sv"></qb-consent-settings>

    <qb-consent-gate category="marketing">
      <template><iframe src="https://www.youtube.com/embed/…"></iframe></template>
      <p>Tillåt marknadsföring för att se videon.</p>
    </qb-consent-gate>
    ```

    Actions: `accept-all`, `reject-all`, `save`, `open-settings`, `close-settings`. Scope: `consent.status`, `.undecided`, `.decided`, `.open`, `.analytics`, `.marketing`, `.labels.*`, `.privacyPolicyUrl`.

    A configured page gets a default banner appended to `<body>`. A `<qb-consent-banner>` of your own, placed before or after, takes its place. An empty `<qb-consent-settings>` renders a labelled button.
  </Tab>
</Tabs>

## Analytics

Analytics comes with the provider. Its options go under `consent.analytics`:

```tsx theme={null}
<ShopkitProvider config={config}>                                       // on, the shop's own ids
<ShopkitProvider consent={{ analytics: { destinations: [tiktok] } }}>   // plus yours
<ShopkitProvider consent={{ analytics: { auto: false, destinations: [googleTagDestination({ ga4MeasurementId: "G-…" })] } }}>
<ShopkitProvider consent={{ analytics: false }}>                        // banner, no pixels
```

| Option | Default | |
| - | - | - |
| `auto` | `true` | Build the Google and Meta destinations from the shop's own ids |
| `destinations` | `[]` | Added to the shop's, or instead of them with `auto: false` |
| `loadBeforeConsent` | `false` | Load Google under a denied Consent Mode before a decision. See [What is gated](#what-is-gated) |
| `requireConsent` | `true` | `false` sends everything ungated, the hosted checkout too. Only for shops that owe no consent |
| `currency` | The cart's | For events fired before a cart exists |
| `bufferSize` | `50` | Events remembered while a destination cannot take them yet |
| `debug` | `false` | Log every dispatch to the console |

The same options work with `configure({ analytics: { … } })` and, for the common ones, on the script tag (`data-analytics="false"`, `data-analytics-require-consent="false"`, `data-analytics-load-before-consent`).

Events a destination cannot receive yet (consent pending, the shop's ids still loading) are remembered and delivered when it can, so a `view_item` fired before the shop answers still reaches GA4. A denial drops what was waiting.

**One decision per page, one hub per shop.** A `<ShopkitProvider>` nested inside another (a [campaign provider](/kit/concepts/campaign-storefronts) inside the site's) reuses the outer consent store, banner and analytics hub, and reports its own cart into the shared hub. A nested provider for a different shop gets its own hub under the same decision.

<Accordion title="Composing the pieces yourself">
  `<ConsentProvider>`, `<AnalyticsProvider>` and `<ConsentBanner>` stay public. The shop provider reuses a `<ConsentProvider>` above it, and an `<AnalyticsProvider>` inside it joins the shop provider's hub, so nothing is created twice:

  ```tsx theme={null}
  <ConsentProvider revision={2} lang="sv">
    <ShopkitProvider config={config} consent={{ analytics: false }}>
      <AnalyticsProvider destinations={[tiktok]}>{children}</AnalyticsProvider>
    </ShopkitProvider>
  </ConsentProvider>
  ```

  The hub itself (`Analytics`) is framework-free and exported from `@quickbutik/kit`:

  ```ts theme={null}
  import { Analytics, destinationsFromShop, viewItemEvent } from "@quickbutik/kit"

  const analytics = new Analytics({ consent })
  analytics.start()
  analytics.attachCart(cartStore)
  for (const d of destinationsFromShop((await shopkit.shop.get()).tracking)) analytics.addDestination(d)
  analytics.track(viewItemEvent(product, variant, "SEK"))
  ```

  Without a consent store the hub fails closed: it treats the shopper as undecided forever and warns once.
</Accordion>

## Events

GA4's vocabulary, money in **major** units, `item_id` the bare numeric product id, the variant as `item_variant`:

| Event | Fired by |
| - | - |
| `view_item` | `<ProductProvider>` / `<qb-product>`, once per product |
| `search` | `useProductSearch` / `<qb-product-list search>`, once per term |
| `add_to_cart` / `remove_from_cart` | Every cart change, with the quantity that changed |
| `page_view` | gtag.js on load; you on SPA navigations (`analytics.trackPageView()`) |
| `view_item_list`, `select_item`, `view_cart` | You (`useAnalytics().track(…)`, `Quickbutik.track(…)`) |
| `begin_checkout`, `add_shipping_info`, `add_payment_info` | The hosted checkout on its own origin; forwarded from the [embedded checkout](/kit/concepts/embedded-checkout) |
| `purchase` | The thank-you page |

The Google destination forwards events with `gtag` (GA4 or Ads id) or `dataLayer.push` (GTM container only), never both. The Meta destination translates to `ViewContent`, `Search`, `AddToCart`, `InitiateCheckout`, `AddPaymentInfo`, `ViewCategory` and `Purchase`.

The kit does not fire `begin_checkout` when the shopper is redirected: the hosted checkout fires it with the same ids, and both would count every checkout twice.

<Tip>
  A single-page app that reports its own navigations passes `sendPageView: false` to the Google destination and calls `analytics.trackPageView()` on route change, so the first page is not counted twice.
</Tip>

## The purchase

In redirect mode **your thank-you page owns the purchase event**, and the kit fires it for you:

```tsx theme={null}
const { status, orderNumber } = useOrderConfirmation()                       // polls, tracks on completion
const { status } = useOrderConfirmation(sessionId, { initialConfirmation })  // server snapshot, tracked either way
useOrderConfirmation(sessionId, { trackPurchase: false })                    // opt out
```

```html theme={null}
<qb-order-confirmation></qb-order-confirmation>
<qb-order-confirmation no-track-purchase></qb-order-confirmation>
```

The payload comes from the checkout session's own lines and total, and is **deduplicated across reloads** with the platform's key (`qb_purchase_tracked_{storeId}_{orderNumber}`). The Meta `Purchase` carries `eventID purchase_{storeId}_{orderNumber}`, the id the platform's server-side events use, so Meta merges rather than double-counts.

Not fired in `inline` success mode (the checkout's confirmation fires it) or on a legacy-checkout shop (no session to read); call `useAnalytics().trackPurchase(purchase, { orderNumber })` there. `useTrackPurchase({ orderNumber })` fires it by hand from an order number you already have. The embedded checkout's purchase reaches your page as an event and the hub forwards it.

## Forwarding consent to the hosted checkout

Every handoff the kit performs (`useCheckout()`, `<qb-checkout-button>`, `<qb-buy-now>`, and `checkout.start()` on a server client) appends the decision to the checkout URL:

```
…/checkout/42/<session>?consentCategories=analytics,marketing&gaClientId=…&gaSessionId=…
```

* The checkout sets Consent Mode from it before its own tags load.
* An undecided shopper is forwarded as `consentCategories=` (nothing granted).
* The GA ids are forwarded only when `analytics` is granted, so GA4 sees one session across both origins. On a server they need the cookie accessor's `getAll()`; without it the decision is still forwarded.
* A URL decorated on the server and again in the browser is never stacked.
* Nothing is appended with `consent: false` in the config.

`appendCheckoutHandoffParams(url, state, { cookies })` is exported for a handoff the kit does not perform.

## Your own destination

```ts theme={null}
import type { AnalyticsDestination } from "@quickbutik/kit"

const tiktok: AnalyticsDestination = {
  id: "tiktok",
  key: "tiktok:C123",              // which account it reports to
  requires: "marketing",           // null = always; gate yourself
  load() { /* the pixel snippet, idempotent */ },
  track(event) {
    if (event.name === "view_item") ttq.track("ViewContent", { contents: event.params.items })
  },
  trackPurchase(purchase, { eventId }) {
    ttq.track("CompletePayment", { event_id: eventId })
  },
}

<ShopkitProvider consent={{ analytics: { destinations: [tiktok] } }}>
```

`requires` names the category a destination needs before it receives anything, `load()` included.

`key` (optional) tells "the same destination again" from "a different one" when `destinations` is an inline array that is recreated on every render: the same key is kept and nothing is replayed into it, a new key is swapped in. Without a `key` the object itself is the identity, so give one to a destination you create inline. The built-ins set it from their ids (`google:G-X|GTM-Y|AW-Z`, `meta:123`); changing another option on the same ids needs a remount.

`googleTagDestination(options)` and `metaPixelDestination({ pixelId })` are exported for ids that are not the shop's.

## Content Security Policy

With analytics on, allow the merchant's vendors:

```
script-src  'self' https://cdn.jsdelivr.net https://www.googletagmanager.com https://connect.facebook.net
connect-src https://commerce.quickbutik.com https://www.google-analytics.com https://*.analytics.google.com https://www.facebook.com
img-src     https://cdn.quickbutik.com https://www.facebook.com https://www.google-analytics.com
```

Every injected tag is `async` and carries `data-qb-analytics`. The kit never evaluates strings.

## Related

<CardGroup cols={2}>
  <Card title="Checkout" icon="cart-shopping" href="/kit/concepts/checkout">
    Redirect and inline modes, the thank-you page and order confirmation.
  </Card>

  <Card title="Storage and SSR" icon="server" href="/kit/concepts/storage-and-ssr">
    Cookie accessors for server clients and rendering without a flash.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.