Skip to content

Cookie consent for DataFast in Next.js without breaking attribution

DataFast sets a year-long first-party cookie, which needs opt-in consent in the EU and UK. Gate the script render, the goal queue and the checkout metadata on one flag.

DataFast2 min readships at docs/solutions/datafast/cookie-consent-for-datafast.md

Tags: datafast · consent · gdpr · cookies · nextjs · privacy

The DataFast script writes datafast_visitor_id, a first-party cookie that lasts a year. It is what makes returning visitors and revenue attribution work.

Under the EU ePrivacy rules and the UK's PECR, storing that on a visitor's device is not "strictly necessary". So in those places the script cannot run until the visitor says yes. Elsewhere, often no banner is needed. That call belongs to whoever owns your privacy policy. This page is about making the code honour it.

The three places data leaves

A consent gate that only covers the script tag leaks in two other places:

  1. The script tag. Rendering it sets the cookie.
  2. Queued goals. Code that calls window.datafast("signup_started") before the script loads pushes onto a queue. If the script loads later, the queue is sent. A goal fired before consent goes out after it.
  3. Checkout metadata. Your server copies the visitor id into Stripe or Polar metadata. If the cookie exists from an earlier visit, the id leaves for a visitor who withdrew consent.

Gate all three on one flag.

One flag, readable on both sides

Store the answer in a first-party cookie, not localStorage. The server can read a cookie. It cannot read localStorage.

export const CONSENT_MODE: "required" | "implied" = "required";
export const CONSENT_COOKIE = "analytics_consent";

export function hasAnalyticsConsent(): boolean {
  if (CONSENT_MODE === "implied") return true;
  if (typeof document === "undefined") return false;
  return document.cookie.split(";").some((c) => c.trim() === `${CONSENT_COOKIE}=granted`);
}

export function grantAnalyticsConsent(): void {
  document.cookie = `${CONSENT_COOKIE}=granted; Max-Age=31536000; Path=/; SameSite=Lax`;
  window.dispatchEvent(new CustomEvent("analytics-consent-change"));
}

Gate the render

Read consent after mount, so server and client HTML agree, and render the tag only when it is true:

"use client";

export function DataFastScript() {
  const [allowed, setAllowed] = useState(false);

  useEffect(() => {
    setAllowed(hasAnalyticsConsent());
    const update = () => setAllowed(hasAnalyticsConsent());
    window.addEventListener("analytics-consent-change", update);
    return () => window.removeEventListener("analytics-consent-change", update);
  }, []);

  if (!allowed) return null;
  return <Script src="/js/script.js" data-website-id="dfid_..." strategy="afterInteractive" />;
}

When the banner calls grantAnalyticsConsent(), the state flips and next/script loads the tag on the spot. No reload.

Gate the queue

export function trackGoal(name: string, params?: Record<string, string>): void {
  if (!hasAnalyticsConsent()) return; // drop, do not queue
  window.datafast?.(name, params);
}

Gate the checkout

const jar = await cookies();
if (CONSENT_MODE === "required" && jar.get(CONSENT_COOKIE)?.value !== "granted") {
  return {}; // no attribution without consent
}

Withdrawal

Withdrawing must be as easy as granting. The script cannot be unloaded once it ran, so delete the consent cookie and reload. The next page view has no script, no queue and no attribution. The old datafast_visitor_id cookie stays until it expires, but nothing reads it any more.

The cost, stated plainly

With consent required, you measure only people who clicked yes. Expect a large drop in visitors and some revenue with no channel. That is the correct number for your jurisdiction, not a bug to fix.

A reverse proxy does not change any of this. It hides a hostname from blocklists. It is not a legal basis.