EventRail
Docs menu

Blocking consent banner

Build a consent banner that has to be answered before the page can be used, and hold events until it is. Together, these make the first recorded page view the page the visitor landed on, with their answer and, if they said yes, their sessionId.

This matters under the optIn and optOut storage policies, where the answer creates or removes the session identifier.

What goes wrong without it

A visitor comes from a Google search and lands on /pricing. They go on to /features, accept there, and then go to /signup. Under optIn, these are the page_view records you get, trimmed to the fields that matter here:

Tracker always on

[
  {
    name: "page_view",
    location: "https://yourapp.com/pricing",
    sessionId: null,The landing page and the Google referrer end up without an identifier.
    consent: "unset",
    producerProperties: { referrer: "https://www.google.com/" }
  },
  {
    name: "page_view",
    location: "https://yourapp.com/features",
    sessionId: null,
    consent: "unset",
    producerProperties: { referrer: "https://yourapp.com/pricing" }
  },
  {
    name: "page_view",
    location: "https://yourapp.com/signup",
    sessionId: "9d2c7e4b-1f8a-4c3d-8e2b-7a6f5e4d3c2b",The session starts on /signup, coming from a page outside it: nothing in it shows the visitor came from Google.
    consent: "accepted",
    producerProperties: { referrer: "https://yourapp.com/features" }
  }
]
  • Tracker always on: one visit becomes two. The landing page and the Google referrer end up in rows without a sessionId, and nothing links them to the session that follows. That session starts on /signup, so acquisition reports can't tell where the visitor came from.
  • Tracker waits, banner doesn't block: the first page view records the page where the visitor answered, paired with the referrer of the page where they landed. The pages before the answer are never recorded, and neither is anyone who ignores the banner.
  • Blocking banner: the visitor answers before they can go anywhere. The page view of the landing page waits behind the banner and goes out with the answer.

Under optOut, the split runs the other way: a visitor who says no leaves a session made up of the pages they saw before answering.

Hold events until the answer

Turn on queueEventsUntilConsentDecisionWasTaken on the browser producer. Until the visitor answers, every track() call is held in memory instead of sent. setConsent() then sends the held events, with the consent and sessionId the answer allows. Each keeps the time, location and properties of its own track() call.

lib/eventrail-client.ts

Next.js · lib/eventrail-client.ts

"use client";
import { createBrowserProducer } from "@eventrail/nextjs/client";

export const producer = createBrowserProducer({
  collectorLocation: "/api/eventrail",
  storeSessionId: "optIn",
  queueEventsUntilConsentDecisionWasTaken: true,Hold events until setConsent() stores an answer.
});

export const { track } = producer;

The queue lives in memory only. A page load, a refresh or closing the tab before the visitor answers discards it. That is why it needs a banner that blocks: with one the visitor can ignore, they can browse across page loads without answering, and every event from those pages is lost.

Build the banner

The gate below blocks the page until the visitor answers. Read the answer on the server and pass it in, so the banner is part of the page's HTML and blocks it before any JavaScript runs.

app/consent-gate.tsx

Next.js · app/consent-gate.tsx

"use client";
import { useState, type ReactNode } from "react";
import type { Consent } from "@eventrail/nextjs/core";
import { producer } from "@/lib/eventrail-client";

type Answer = "accepted" | "denied";
type Props = { initialConsent?: Consent; children: ReactNode };

export function ConsentGate({ initialConsent, children }: Props) {
  const consent = producer.useConsent(initialConsent);
  const [cookiesRefused, setCookiesRefused] = useState(false);
  const blocked = consent === "unset" && !cookiesRefused;

  function answer(choice: Answer) {
    try {
      producer.setConsent(choice);Stores the answer and sends the events held behind the banner.
    } catch {
      setCookiesRefused(true);The browser refused the cookie, so no answer can be stored. Let the visitor in anyway.
    }
  }

  return (
    <>
      <div inert={blocked}>{children}</div>inert puts the page out of reach of mouse, keyboard and screen reader until the visitor answers.
      {blocked ? <ConsentBanner onAnswer={answer} /> : null}
    </>
  );
}

function ConsentBanner({ onAnswer }: { onAnswer: (choice: Answer) => void }) {
  return (
    <div
      style={{
        position: "fixed",
        inset: 0,
        display: "grid",
        placeItems: "center",
        background: "rgb(0 0 0 / 0.5)",
      }}
    >
      <div role="dialog" aria-modal="true" aria-labelledby="consent-title" style={{ background: "white" }}>
        <h2 id="consent-title">Can we measure how you use this site?</h2>
        <p>We record which pages you visit and what you click, in our own database.</p>
        <button onClick={() => onAnswer("denied")}>Decline</button>
        <button onClick={() => onAnswer("accepted")}>Accept</button>
      </div>
    </div>
  );
}
app/layout.tsx

Next.js · app/layout.tsx

import { cookies } from "next/headers";
import { CONSENT_COOKIE, parseConsent } from "@eventrail/nextjs/core";
import { TrackErrors, TrackPageViews } from "@eventrail/nextjs/client";
import { track } from "@/lib/eventrail-client";
import { ConsentGate } from "./consent-gate";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const consent = parseConsent((await cookies()).get(CONSENT_COOKIE)?.value);

  return (
    <html lang="en">
      <body>
        <ConsentGate initialConsent={consent}>{children}</ConsentGate>
        <TrackPageViews track={track} />Mounted as usual: its first page_view waits for the answer.
        <TrackErrors track={track} />
      </body>
    </html>
  );
}

The trackers stay where Getting started put them: the producer holds their events, so they don't have to wait for the answer themselves.

In Next.js, calling cookies() in the root layout renders every page dynamically. If your pages have to stay static, leave out the server read and render <ConsentGate> without initialConsent. The banner then appears once the page has hydrated, and until then the page can be used. Events from that moment are held all the same.

  • What the banner has to offer the visitor is covered in Consent.
  • Events are recorded after either answer, the held ones included. After a no, they arrive without a sessionId, as the storage policy intends. To record nothing after a no, drop them in the Hub, for example with your own destination that skips records whose consent is "denied".
  • A browser that refuses cookies can't store an answer. setConsent() then throws. The gate lets the visitor in anyway, but with no answer stored, the producer keeps holding events. They are discarded at the next page load, and past 100 held events, further ones go to the producer's onError.
  • An answer given in another tab is picked up at this tab's next track() call.