Ship a cookie banner with your TanStack app
Wire a sub-4kb headless consent state machine into your app and render the banner in your own components — with an optional Vite plugin that fails the build on any cookie that isn't gated.
Jamie Davenport
·tanstack-cookie-banner
Cookie banners have a deserved reputation as the worst part of the web. The default options are a pre-styled iframe that flashes in on every page load, a third-party script that ignores your design system, or a hand-rolled useState that forgets every refresh and silently fails the audit.
OpenCookies takes a different shape. The runtime is a sub-4kb headless state machine — categories, decisions, jurisdiction, GPC, persistence — with framework adapters for React, Vue, Solid, Svelte, and Angular. There is no bundled UI. You build the banner with the same primitives you build everything else with. This post walks through dropping it into a TanStack Start project end-to-end.
What you actually get
Two required pieces, plus one optional one:
- The state machine.
@opencookies/coreholds the consent record, exposes actions (acceptAll,acceptNecessary,toggle,save), and persists to localStorage, cookies, or your own adapter. It resolves the visitor's jurisdiction, honours Global Privacy Control where the law requires it, and re-prompts when the policy version changes or you add a category. - The reactivity layer.
@opencookies/reactwraps the store inuseSyncExternalStoreso concurrent React stays happy. You getuseConsent(),useCategory(key), and a<ConsentGate>that conditionally renders by consent expression —"analytics",{ and: ["analytics", "marketing"] },{ not: "marketing" }. - An optional build-time check.
@opencookies/viteis a dev-only safety net — it runs a static scanner on every dev start and HMR update, flagging cookie writes and vendor calls that aren't gated. Skip it entirely if you don't want it; everything else works on its own. Add it later when you want the class of bug where someone copies a Hotjar snippet into a layout file to stop compiling.
The point of the split is that the visual banner — the bit you actually see — is just your components. No theme to override, no !important to fight, no FOUC while a third-party script boots. It looks like the rest of your app because it is the rest of your app.
The fast path: hand it to your coding agent
Because OpenCookies ships primitives instead of UI, you can describe the banner you want and let Claude Code or Cursor write it. There's no theme system to learn — the agent reads your existing components, picks up your Tailwind tokens or CSS variables, and produces a banner that matches. A prompt that works well:
Read the OpenCookies docs first:
https://github.com/jamiedavenport/opencookies
The package READMEs in /packages/{core,react,vite,scripts} cover
the full API — fetch them before writing any code.
Then add a cookie consent banner to this TanStack Start app using
@opencookies/core, @opencookies/react, and @opencookies/vite.
- Categories: essential (locked), analytics, marketing.
- Persist with the localStorage adapter.
- Resolve jurisdiction with the timezone resolver.
- Wrap the app in OpenCookiesProvider in __root.tsx.
- Build the banner with my existing button + dialog primitives —
match the visual style of the components already in src/components.
- Optionally add the Vite plugin in warn mode for dev, error mode
for build — only if I want build-time enforcement.
- Gate any third-party analytics scripts with gateScript().What you get back is a banner wired to the store and rendered with your components, optionally with the Vite plugin enforcing gating on the rest of your codebase. The rest of this post walks through the same setup by hand so you know what the agent is producing.
Install
bun add @opencookies/core @opencookies/reactcore is the state machine. react is the adapter — hooks plus <ConsentGate>. That's everything you need at runtime; the rest of this post wires those two packages up. The optional @opencookies/vite build-time check has its own section at the end.
Mount the provider
Wrap the app once at the root. In TanStack Start that's the __root route:
import { OpenCookiesProvider } from "@opencookies/react";
import type { Category } from "@opencookies/core";
import { Outlet, createRootRoute } from "@tanstack/react-router";
const categories: Category[] = [
{ key: "essential", label: "Essential", locked: true },
{ key: "analytics", label: "Analytics" },
{ key: "marketing", label: "Marketing" },
];
export const Route = createRootRoute({
component: RootComponent,
});
function RootComponent() {
return (
<OpenCookiesProvider config={{ categories }}>
<Outlet />
</OpenCookiesProvider>
);
}locked: true means essential is always granted and the user can't toggle it off — that's the cookies your app needs to function (session, CSRF, the consent record itself). Anything else is opt-in.
Persist decisions and resolve jurisdiction
The provider's config shorthand is fine for a client-only app, but the moment you want SSR-resolved decisions, region-aware defaults, or anything other than localStorage you'll want to build the store yourself with createConsentStore and pass it in via store={store}. Two pieces matter: the storage adapter (where decisions live) and the jurisdiction resolver (where the visitor lives).
import { createConsentStore, timezoneResolver } from "@opencookies/core";
import { localStorageAdapter } from "@opencookies/core/storage/local-storage";
export const store = createConsentStore({
categories: [
{ key: "essential", label: "Essential", locked: true },
{ key: "analytics", label: "Analytics" },
{ key: "marketing", label: "Marketing" },
],
adapter: localStorageAdapter(),
jurisdictionResolver: timezoneResolver(),
});Then mount it:
import { OpenCookiesProvider } from "@opencookies/react";
import { store } from "@/lib/consent-store";
// ...
<OpenCookiesProvider store={store}>
<Outlet />
</OpenCookiesProvider>Storage adapters decide where the consent record gets written and how it survives a refresh. Three ship in the box:
@opencookies/core/storage/local-storage— browser localStorage with cross-tab sync. The right default for a client-rendered SPA: zero network, the user's other tabs see the change immediately.@opencookies/core/storage/cookie—document.cookiewith configurable name, domain, and Max-Age. Pick this when the server needs to read consent on the request (e.g. to omit the analytics script tag entirely on SSR for users who declined) or when you want the record to span subdomains.@opencookies/core/storage/server— header-based reader for SSR runtimes. You build the store on the server with this adapter, hydrate the user's existing decision before the first paint, and avoid the banner-flash where someone who clicked "accept" two weeks ago still sees the banner for half a second on every visit.
Custom backends — IndexedDB, your own database, an auth-tied per-user record — implement the StorageAdapter interface (read, write, clear, optional subscribe). The adapter is just a port; the store doesn't care.
Jurisdiction resolvers decide what region's defaults apply before the user clicks anything. There's deliberately no default — if you omit jurisdictionResolver, state.jurisdiction stays null and you've opted out of region-aware behavior. Four resolvers ship:
headerResolver()— reads country headers set by your edge (Vercelx-vercel-ip-country, Cloudflarecf-ipcountry, etc.). The most accurate option, server-only.timezoneResolver()— derives the region from the browser's IANA timezone. Zero network, decent accuracy, runs on the client. Good first choice for a client-rendered app.manualResolver("eu" | "us-ca" | …)— pin the jurisdiction to a fixed value. Useful in tests and Storybook so you can render the EU and California variants of the banner deterministically.clientGeoResolver({ endpoint })— fetches from a geo endpoint you operate. Slowest of the four; reach for it only when the others won't do.
Why this matters: GDPR demands opt-in (categories default to denied, banner shows on first visit), CCPA/CPRA demands a "Do Not Sell or Share" affordance and respects Global Privacy Control as a legally binding signal in California, and most other regions sit somewhere in between. The store reads the resolver once on init, applies the right defaults, and exposes state.jurisdiction so your banner copy can branch on it. Core also exports GPC_LEGALLY_REQUIRED_JURISDICTIONS so you can scope GPC to the US states where it's legally binding instead of treating every visitor's browser signal as a binding opt-out.
Build the banner
useConsent() is the primary hook. It returns the current state plus actions:
import { useConsent } from "@opencookies/react";
export function CookieBanner() {
const { route, acceptAll, acceptNecessary, setRoute } = useConsent();
if (route !== "cookie") return null;
return (
<div className="fixed inset-x-4 bottom-4 rounded-2xl border border-line bg-bg p-5 shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-sm">
<p className="text-sm text-mute">
We use cookies to keep the app running and, with your permission, to
understand how it's used. You can change this any time in settings.
</p>
<div className="mt-4 flex gap-2">
<button onClick={acceptNecessary} className="btn btn-ghost flex-1">
Necessary only
</button>
<button onClick={() => setRoute("preferences")} className="btn btn-ghost flex-1">
Customize
</button>
<button onClick={acceptAll} className="btn btn-primary flex-1">
Accept all
</button>
</div>
</div>
);
}route is the store's view of which surface should be visible — "cookie" for the banner, "preferences" for the detail panel, and a hidden state once a decision is on file. The hook re-renders the consumer when state changes, so you don't manage any of that yourself.
The preferences panel is a second component reading useCategory(key) for each non-locked category:
import { useConsent, useCategory } from "@opencookies/react";
function Toggle({ k, label }: { k: "analytics" | "marketing"; label: string }) {
const { granted, toggle } = useCategory(k);
return (
<label className="flex items-center justify-between py-3">
<span>{label}</span>
<input type="checkbox" checked={granted} onChange={toggle} />
</label>
);
}
export function CookiePreferences() {
const { route, save, setRoute } = useConsent();
if (route !== "preferences") return null;
return (
<div className="fixed inset-0 grid place-items-center bg-bg/70 p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-bg p-6">
<h2 className="text-lg font-medium">Cookie preferences</h2>
<Toggle k="analytics" label="Analytics" />
<Toggle k="marketing" label="Marketing" />
<div className="mt-4 flex justify-end gap-2">
<button onClick={() => setRoute("cookie")} className="btn btn-ghost">Back</button>
<button onClick={save} className="btn btn-primary">Save</button>
</div>
</div>
</div>
);
}toggle flips a single category in the store's draft. save commits the draft as a ConsentRecord — versioned, timestamped, with the resolved jurisdiction and the source ("banner", "preferences", "api", "import") attached so you can audit later.
Gate content with <ConsentGate>
For inline UI that depends on consent — an analytics chart, a personalised promo — use the gate component. It accepts a single category key or a ConsentExpr, and renders fallback when the expression isn't satisfied:
import { ConsentGate } from "@opencookies/react";
function EnableAnalyticsPrompt() {
return <p className="text-sm text-mute">Enable analytics to see this chart.</p>;
}
export function Dashboard() {
return (
<ConsentGate requires="analytics" fallback={<EnableAnalyticsPrompt />}>
<UsageChart />
</ConsentGate>
);
}The expression syntax composes: { and: ["analytics", "marketing"] } for both, { or: [...] } for either, { not: "marketing" } for "show this when marketing is denied". The component itself emits no DOM wrapper, so layout stays clean.
Gate third-party scripts
Inline UI is the easy half. The hard half is the analytics SDK that wants to load on first paint and read every page navigation. gateScript() from core handles that — it stubs the global (gtag, fbq, etc.) before the script loads, queues calls made before consent, and replays them once the script is on the page and consent is granted:
import { defineScript, gateScript } from "@opencookies/core";
import { store } from "@/lib/consent-store";
const ga4 = defineScript({
id: "ga4",
requires: "analytics",
src: "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX",
queue: ["dataLayer.push"],
init: () => {
window.dataLayer = window.dataLayer || [];
window.gtag = function gtag() {
window.dataLayer.push(arguments);
};
},
});
gateScript(store, ga4);gateScript is a free function rather than a method on the store, so script-gating code that you don't use gets tree-shaken out of the bundle. The @opencookies/scripts package ships pre-built defineScript factories for GA4, Meta Pixel, PostHog, Segment, GTM, and Hotjar if you'd rather not hand-roll it — the factory takes the credentials, you pass the result to gateScript.
For SSR-rendered apps, the gating happens at the boundary where the script would load on the client; the rest of the app renders normally on the server.
Optional: catch ungated cookies at dev time
Everything above is the runtime — the banner, the gates, the script gating, the persistence. You can ship that and stop here. The Vite plugin in this section is a separate, opt-in safety net: it doesn't affect the runtime at all, it just watches your source for cookie writes and vendor calls that aren't wrapped in a consent gate, and tells you about them while you're typing.
If that sounds useful, install it as a dev dependency:
bun add -D @opencookies/viteThen add openCookies to your plugin list. Like most source-scanning plugins, it has to come before tanstackStart() so it sees the raw input:
import { openCookies } from "@opencookies/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [
tsConfigPaths(),
openCookies({
config: {
categories: [
{ key: "essential", label: "Essential", locked: true },
{ key: "analytics", label: "Analytics" },
{ key: "marketing", label: "Marketing" },
],
},
}),
tanstackStart(),
viteReact(),
],
});On every dev start and HMR update it runs the scanner across your source and reports:
[opencookies] 1 ungated finding
src/components/upsell.tsx:42:5
rule: no-ungated-cookie
fix: wrap in <ConsentGate requires="marketing"> or move into a gated effect
suppress: // opencookies-disable-next-line no-ungated-cookieIn dev that's a yellow warning. In CI it's a non-zero exit. The bug pattern this kills is the one where someone copies a vendor snippet into a layout file three weeks before an audit and nobody notices — because the build passes, the tests pass, and a cookie banner that looks correct in the browser is still letting Hotjar fire on first paint regardless of what the user clicked.
If you genuinely want the cookie set unconditionally — a session cookie, say — the suppression comment makes it explicit. The diff has to acknowledge the gap, which is a much better default than silence.
You can add this on day one or on the day before your first audit; the runtime doesn't change either way.
Why this shape
The design choice that makes the rest of it work is the split between consent logic and consent UI. Once those are separate:
- The banner stays in your component library. It uses your tokens, your buttons, your dialog primitive. There's nothing to re-skin when you redesign the rest of the app.
- The state machine is testable. Decisions, GPC, jurisdiction, re-consent triggers — all of that is plain functions over a store. You can unit-test "GPC sets analytics to denied" without rendering anything.
- The scanner is optional but honest. Because gating is explicit (
<ConsentGate>,gateScript,useCategory), a missing gate is a static fact the scanner can find — if you opt into the Vite plugin. Compare to the alternative — a runtime banner library that hopes you remembered to callif (consent.analytics)before every fetch. - AI does the boring work. When the framework is primitives, an agent that reads your component library can produce a banner that looks like it was always there. There's no "OpenCookies theme" to fight; there's just your design system.
Where to go next
You've got a banner that lives in your components, a state machine that handles the parts you'd otherwise get wrong, and — if you want it — a build-time check that fails the PR when someone forgets the gate. Two adjacent pieces of the PolicyStack turn that into a complete cookie story:
- OpenPolicy — the policy half. The same pattern, applied to your privacy policy and cookie policy: TypeScript config, React renderer, Vite plugin that scans for undeclared third parties. The categories you defined here are the categories that show up in the rendered cookie policy. Walkthrough in Ship a privacy policy with your TanStack app.
- Open source — Apache-2.0, pre-1.0 but actively developed. Issues and PRs welcome; the framework adapters are deliberately small so adding a new one is a weekend.
- PolicyCloud — the hosted control plane. Versioning and audit trails for consent records, a PR bot that flags when a category is added without policy updates, and the dashboards your privacy team actually wants to look at.
Or browse the React example for a working end-to-end project you can fork.