# Consent gating a chat widget without breaking it

> Chat sets cookies before anyone types a word, so hiding the launcher is not compliance. Gate the script tag itself, make withdrawal real, and know why a reload is the only honest teardown.

Someone from legal asks whether the chat widget sets cookies before consent.
You check: the launcher only appears in the corner, nobody has typed anything,
surely not. Then you open Application → Cookies on a fresh incognito load and
find `crisp-client/session/...` sitting there, set within a second of the page
loading, before any interaction at all.

This is normal for every hosted chat widget, and it is a problem in every
jurisdiction with an opt-in cookie regime.

## What the law actually requires

Under the ePrivacy Directive (and the UK's PECR), storing or reading information
on a user's device requires consent unless it is *strictly necessary for a
service the user explicitly requested*. GDPR then governs what you do with the
resulting personal data.

The exemption is narrower than teams hope:

- A session cookie that keeps a user logged in: strictly necessary.
- A cookie that remembers a shopping basket: strictly necessary.
- A chat widget that loads on every page, sets a persistent identifier, and
  tracks a visitor across sessions in case they might want support: **not**
  strictly necessary. The user did not request a support session by loading your
  pricing page.

A defensible argument exists for loading chat *after* a user clicks "chat with
us", at that point they did request it. That is the on-demand pattern at the
end of this document, and it is both the most compliant and the fastest option.

## Why hiding the launcher is not a gate

```tsx
// DON'T: the cookies are already set
<Script src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
{consented ? null : <style>{`.crisp-client { display: none }`}</style>}
```

The cookie is written when the script executes. CSS applied afterwards hides a
UI; it does not un-write storage, close the websocket or forget the identifier.
The same applies to `$crisp.push(["do", "chat:hide"])`: it is a visibility
command, not a privacy control.

The gate has to be on whether the script runs at all.

## The gate

Keep the decision in one module so the policy is one edit, and so any cookie
banner can drive it:

```ts
// src/lib/support/consent.ts
export type ConsentMode = "required" | "implied";

export const CONSENT_MODE: ConsentMode = "required";

const STORAGE_KEY = "support-consent";
const EVENT = "support-consent-change";

export function hasSupportConsent(): boolean {
  if (CONSENT_MODE === "implied") return true;
  if (typeof window === "undefined") return false;
  try {
    return window.localStorage.getItem(STORAGE_KEY) === "granted";
  } catch {
    return false; // storage blocked: no record of consent means no consent
  }
}

export function grantSupportConsent(): void {
  try {
    window.localStorage.setItem(STORAGE_KEY, "granted");
  } catch {
    // ignore: the widget loads for this page view and asks again next time
  }
  window.dispatchEvent(new CustomEvent(EVENT, { detail: true }));
}

export function onSupportConsentChange(listener: (granted: boolean) => void): () => void {
  const handler = () => listener(hasSupportConsent());
  window.addEventListener(EVENT, handler);
  window.addEventListener("storage", handler); // another tab accepted
  return () => {
    window.removeEventListener(EVENT, handler);
    window.removeEventListener("storage", handler);
  };
}
```

Then the widget renders nothing at all until consent exists:

```tsx
"use client";

export function CrispWidget() {
  const [consented, setConsented] = useState(false);

  // Read after mount: localStorage does not exist on the server, and reading it
  // during render would produce different markup on each side and a hydration
  // error that React papers over in production.
  useEffect(() => {
    setConsented(hasSupportConsent());
    return onSupportConsentChange(setConsented);
  }, []);

  if (!consented) return null;

  return <Script id="crisp-widget" src="https://client.crisp.chat/l.js" strategy="lazyOnload" />;
}
```

Wire your banner's accept button to `grantSupportConsent()`. Because the widget
subscribes to the change event, the chat appears the moment someone accepts:
no reload needed in that direction.

## Withdrawal has to be real, and it needs a reload

Consent must be as easy to withdraw as to give. The awkward truth is that Crisp
(like most chat widgets) has no teardown API. Once the script has run it has
opened a websocket, mounted an iframe and written its cookies, and there is no
`unload()` to call.

So withdrawal reloads:

```ts
export function revokeSupportConsent(): void {
  try {
    window.localStorage.removeItem(STORAGE_KEY);
  } catch {
    // nothing to remove
  }
  window.dispatchEvent(new CustomEvent(EVENT, { detail: false }));
  window.location.reload(); // the only honest way to stop a script already running
}
```

If you also need to delete the cookies the widget set, do it before the reload:
enumerate `document.cookie`, and expire anything prefixed `crisp-`. Some are set
on the vendor's own domain inside the iframe and are not yours to remove: say
so in your privacy policy rather than pretending otherwise.

## Deciding your default

`CONSENT_MODE` is a policy decision, not a technical one, and it belongs to
whoever owns the privacy policy:

- **`"required"`**: the default here. Correct for the EU, the UK, and anywhere
  else with an opt-in regime. Nothing loads until someone accepts.
- **`"implied"`**: the widget loads at idle for everyone. Only defensible where
  your users are outside those jurisdictions and your privacy policy covers it.

Do not implement "load for signed-in users only" as a consent workaround. Being
signed in is not consent to third-party tracking cookies; it is consent to your
service.

## The pattern that avoids the question entirely

Load on intent. Render your own button; inject the script on first click:

```tsx
{wanted ? (
  <Script
    id="crisp-widget"
    src="https://client.crisp.chat/l.js"
    strategy="lazyOnload"
    onLoad={() => window.$crisp?.push(["do", "chat:open"])}
  />
) : null}
```

Now the user has explicitly requested a support session before anything is
stored, which is the clearest form of the strictly-necessary argument. It is
also the fastest option for the majority of visitors who never click, and it
removes the widget from your cookie banner's "marketing" category entirely.

Whichever route you take, keep two things in sync: the cookie banner's
description of what chat does, and the reality of when the script runs. The
mismatch between those two is what an audit actually finds.

## Verifying

1. Fresh incognito window, decline consent, load several pages. Application →
   Cookies must contain no `crisp-*` entries, and the Network tab must show no
   request to `client.crisp.chat`.
2. Accept. The widget appears without a reload.
3. Withdraw. The page reloads and, on the next load, no cookies and no request.
4. In a second tab, accept: the first tab should react to the `storage` event.
5. Block `localStorage` (Safari private mode, or "block all cookies") and
   confirm the widget stays off rather than throwing.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
