consent
In much of the world you need a visitor's permission before you load analytics, marketing pixels or session replay. @kitr/consent keeps one record per visitor of what they agreed to, by category, in a cookie that the server can read too. While a category is not granted, analytics keeps its calls in a queue and, if you ask it to, monitoring drops its reports. Your banner is a few lines over useConsent(), so it looks like the rest of your app.
The examples on this page follow one scenario: Acme's app at app.acme.com asks Ada for consent on her first visit, holds Google Analytics until she agrees, loads a marketing pixel only for the marketing category, and lets Grace change her mind from the settings page.
@kitr/consent runs in the browser: the provider only gates analytics and monitoring on the client. The record lives in a cookie made with @kitr/storage, so server code can read it from the request. It is not a legal template: you decide the categories and the wording, and your lawyer decides whether they are enough.
Install
kitr add consentFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/consent, which brings@kitr/storagealong as a dependency for its cookie. - Adds
() => import('@kitr/consent/provider')to the end ofprovidersand'@kitr/consent'topackagesinkitrrc.ts. - Writes
config/consent.ts(shown in Configure) and regeneratesconfig/index.ts. - Writes
app/components/consent-banner.tsx, a banner with "Only necessary" and "Accept all" buttons, styled with Tailwind classes. - Adds a
## kitr: consentsection toAGENTS.mdtelling coding agents that the banner is the one place consent is asked, never to callanalytics.enable()by hand, and to checkuseConsent().has('marketing')before loading third-party scripts.
kitr add consent asks no questions and writes no env variables. To edit package.json without running the package manager, pass the global flag:
kitr add consent --no-installFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
No kit includes consent. kitr add consent adds storage first when the project lacks it, so config/storage.ts sets the cookie's prefix, path and secure.
kitr remove consent removes the provider, the packages entry, the dependency and the AGENTS.md section, and deletes config/consent.ts. It leaves app/components/consent-banner.tsx and the place you mounted it, which you delete by hand. Visitors keep their consent cookie until it expires.
Configure
kitr add consent writes this file:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['analytics', 'marketing'],
version: 1, // bump to ask everyone again
analytics: 'analytics', // analytics waits for this category
monitoring: false, // error reports are not gated
})
| Option | Default | Description |
|---|---|---|
categories |
['analytics', 'marketing'] |
The categories your banner offers. necessary is always granted and never needs listing. grant() ignores any category not in this list. |
version |
1 |
The version of your consent text. A stored choice with another version counts as undecided, so bumping it asks everyone again. |
cookie |
'consent' |
The cookie name, before the storage prefix is added. |
maxAge |
365 |
Days the browser keeps the choice. |
analytics |
'analytics' |
The category analytics waits for. false leaves analytics alone. |
monitoring |
false |
The category monitoring waits for. false (or leaving it out) never holds error reports back. |
The cookie is written with SameSite=Lax, the maxAge above, and the cookie defaults from config/storage.ts (path and secure). Its name gets the storage prefix, so with prefix: 'acme-app:' the browser stores acme-app:consent.
The record inside it is JSON:
{ "granted": ["necessary", "analytics"], "decidedAt": "2026-11-07T09:30:00.000Z", "version": 1 }
With the consent provider in kitrrc.ts, analytics starts disabled while the app boots, whatever enabled says in config/analytics.ts and wherever the two providers sit in the list. No vendor script (gtag.js, Plausible or PostHog) loads, and no vendor cookie is set, until the visitor grants the analytics category. You do not need to change config/analytics.ts.
Show your first banner
kitr add wrote this banner:
// app/components/consent-banner.tsx
import { useConsent } from '@kitr/consent/react'
/** Mount once in the root layout. Renders nothing once a choice is recorded. */
export function ConsentBanner() {
const { decided, acceptAll, rejectAll } = useConsent()
if (decided) return null
return (
<div role="dialog" aria-label="Cookie consent" className="fixed inset-x-0 bottom-0 flex flex-wrap items-center justify-center gap-3 border-t bg-background p-4 text-sm">
<span>We use cookies for analytics. You can change your mind in settings.</span>
<button type="button" className="rounded-md border px-3 py-1" onClick={rejectAll}>
Only necessary
</button>
<button type="button" className="rounded-md bg-foreground px-3 py-1 text-background" onClick={acceptAll}>
Accept all
</button>
</div>
)
}
Mount it once, in the document. Keep the rest of app/document.tsx as it is and render the banner after the <Outlet />:
// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { ConsentBanner } from '#app/components/consent-banner'
// ...
export function RootDocument() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Outlet />
<ConsentBanner />
<Scripts />
</body>
</html>
)
}
Open app.acme.com in a private window. The banner sits at the bottom of the page. Click "Accept all" and:
- The banner disappears, because
decidedis nowtrue. - A
consentcookie holds{ granted: ['necessary', 'analytics', 'marketing'], decidedAt, version: 1 }. - The provider calls
analytics.enable(), which loads the analytics scripts and sends every call that was queued since the page loaded, the first page view included.
Reload the page and the banner stays hidden: the choice is read back from the cookie. Click "Only necessary" instead and the cookie records ['necessary']; analytics keeps queuing and never sends.
Offer a choice per category
grant() records exactly the categories you pass, plus necessary. Use it for a banner with checkboxes:
// app/components/consent-banner.tsx
import { useConsent } from '@kitr/consent/react'
import { useState } from 'react'
const labels: Record<string, string> = {
analytics: 'Usage statistics, so we know which pages help',
marketing: 'Marketing, so our ads reach people like you',
}
export function ConsentBanner() {
const { decided, categories, grant, acceptAll, rejectAll } = useConsent()
const [chosen, setChosen] = useState<string[]>([])
if (decided) return null
return (
<div role="dialog" aria-label="Cookie consent">
<p>Acme uses cookies. Pick what you allow.</p>
{categories.map((category) => (
<label key={category}>
<input
type="checkbox"
checked={chosen.includes(category)}
onChange={(e) =>
setChosen((c) => (e.target.checked ? [...c, category] : c.filter((x) => x !== category)))
}
/>
{labels[category] ?? category}
</label>
))}
<button type="button" onClick={rejectAll}>Only necessary</button>
<button type="button" onClick={() => grant(chosen)}>Save choice</button>
<button type="button" onClick={acceptAll}>Accept all</button>
</div>
)
}
categories comes from config/consent.ts, so adding a category there adds a checkbox. What each action records:
| Call | granted afterwards |
|---|---|
acceptAll() |
['necessary', 'analytics', 'marketing'] |
rejectAll() |
['necessary'] |
grant(['analytics']) |
['necessary', 'analytics'] |
grant(['analytics', 'chat']) |
['necessary', 'analytics'], because chat is not in categories |
grant([]) |
['necessary'], same as rejectAll() |
Every one of them sets decidedAt to the current time, writes the cookie and notifies subscribers.
Add a category
Categories are plain strings. Add one to config/consent.ts and it appears in categories, so a banner that maps over them shows it:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['analytics', 'marketing', 'preferences'],
version: 2, // new category: ask everyone again
analytics: 'analytics',
monitoring: false,
})
Then check it where the feature lives. Here the projects list only remembers Linus's sort order when he allowed preferences:
// app/hooks/projects/use-sort-order.ts
import { useConsent } from '@kitr/consent/react'
import { useEffect, useState } from 'react'
export function useSortOrder() {
const { has } = useConsent()
const remember = has('preferences')
const [order, setOrder] = useState<'name' | 'updated'>('updated')
useEffect(() => {
if (!remember) return
const saved = localStorage.getItem('acme:project-sort')
if (saved === 'name' || saved === 'updated') setOrder(saved)
}, [remember])
const change = (next: 'name' | 'updated') => {
setOrder(next)
if (remember) localStorage.setItem('acme:project-sort', next)
}
return [order, change] as const
}
Bump version whenever you add a category, so people who already chose are asked about the new one.
Hold analytics for a different category
By default analytics waits for analytics. Point it at another category, or turn the gate off:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['statistics', 'marketing'],
version: 1,
analytics: 'statistics',
monitoring: false,
})
With analytics: false, consent never calls analytics.enable() or disable(). Use it when you run a cookieless service you are allowed to use without consent. Analytics then starts as its own enabled option says, true by default.
Load a second tracker for one category
Some trackers belong to a different category from your page views, such as a marketing service that sees every event. Add its adapter only after that category is granted, with analytics.use():
// app/components/marketing-tracker.tsx
import { posthog } from '@kitr/analytics'
import { useAnalytics } from '@kitr/analytics/react'
import { useConsent } from '@kitr/consent/react'
import posthogJs from 'posthog-js'
import { useEffect } from 'react'
export function MarketingTracker() {
const { has } = useConsent()
const analytics = useAnalytics()
const allowed = has('marketing')
useEffect(() => {
if (!allowed || analytics.adapters.some((a) => a.name === 'posthog')) return
analytics.use(posthog(posthogJs, { apiKey: 'phc_acme_marketing' }))
}, [allowed, analytics])
return null
}
The adapter receives calls made after it was added. Withdrawing marketing does not remove it; if analytics itself stays enabled, the adapter keeps receiving events until the next page load. Gate such a tracker on the same category as analytics if that matters to you.
Keep the choice for less time
Some regulators expect you to ask again after six months. Set maxAge in days, and rename the cookie if another app on the same domain already uses consent:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['analytics', 'marketing'],
version: 1,
cookie: 'acme_consent',
maxAge: 180,
analytics: 'analytics',
monitoring: false,
})
When the cookie expires the browser deletes it, the record reads as undecided, and the banner shows again.
Show the banner without a flash
On the server, the consent service has no cookie to read, so server-rendered HTML always contains the banner. useConsent() hydrates with that same undecided state, so React reports no mismatch, then reads the cookie: for a visitor who already chose, the banner shows for a moment and goes away. To avoid that flash, render the banner only after the component has mounted:
// app/components/consent-banner.tsx
import { useConsent } from '@kitr/consent/react'
import { useEffect, useState } from 'react'
export function ConsentBanner() {
const { decided, acceptAll, rejectAll } = useConsent()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted || decided) return null
return (
<div role="dialog" aria-label="Cookie consent">
<p>We use cookies for analytics. You can change your mind in settings.</p>
<button type="button" onClick={rejectAll}>Only necessary</button>
<button type="button" onClick={acceptAll}>Accept all</button>
</div>
)
}
The server and the first client render both produce nothing, so nothing flashes. Right after mount, the banner appears only for visitors who have not decided. In a single-page app (web spa mode), there is no server HTML and the generated banner works as it is.
Check a category before loading a script
For a script that is not a kitr package, such as an ad pixel or a chat widget, check its category first. useConsent() re-renders when the choice changes, so the script loads the moment the visitor agrees:
// app/components/marketing-pixel.tsx
import { useConsent } from '@kitr/consent/react'
import { useEffect } from 'react'
export function MarketingPixel() {
const { has } = useConsent()
const allowed = has('marketing')
useEffect(() => {
if (!allowed || document.getElementById('acme-pixel')) return
const script = document.createElement('script')
script.id = 'acme-pixel'
script.async = true
script.src = 'https://pixel.acme.com/p.js'
document.head.appendChild(script)
}, [allowed])
return null
}
Mount it next to the banner. has('necessary') is always true; any other category is true only when granted in the current version.
Don't put the vendor's <script> tag straight into the root layout's head. It would load for every visitor before they answer.
Let visitors change their mind
Visitors must be able to withdraw consent as readily as they gave it. Give your settings page a button that calls reset(), which deletes the cookie so the banner shows again:
// app/routes.ts
app.page('/settings/privacy', { lazy: () => import('./pages/settings/privacy') })
// app/pages/settings/privacy.tsx
import { useConsent } from '@kitr/consent/react'
export default function PrivacySettings() {
const { record, decided, has, grant, reset } = useConsent()
return (
<main>
<h1>Privacy</h1>
<p>
{decided
? `You chose on ${new Date(record.decidedAt!).toLocaleDateString('en-US')}.`
: 'You have not chosen yet.'}
</p>
<label>
<input
type="checkbox"
checked={has('analytics')}
onChange={(e) =>
grant(
record.granted.filter((c) => c !== 'analytics').concat(e.target.checked ? ['analytics'] : []),
)
}
/>
Usage statistics
</label>
<button type="button" onClick={reset}>Ask me again</button>
</main>
)
}
When Grace unticks "Usage statistics", grant() records the remaining categories and the provider calls analytics.disable(). Calls stop going out and the queue is emptied. Scripts that already loaded stay in the page until the next full reload, because a browser cannot unload a script.
reset() sets the record back to undecided, removes the cookie and notifies subscribers. The provider sees that analytics is no longer granted and calls analytics.disable(), so nothing is sent until the visitor chooses again.
Ask everyone again
When you add a vendor or change what a category covers, bump version:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['analytics', 'marketing'],
version: 2, // added PostHog session replay to analytics
analytics: 'analytics',
monitoring: false,
})
A stored record with version: 1 now reads as undecided: decided is false, has('analytics') is false, and the banner shows again. The old cookie stays until the visitor chooses, then it is overwritten. A cookie whose JSON has the wrong shape (for example granted that is not an array) is also treated as undecided.
Gate error reports
Error reports can contain page content and who the visitor is, so some teams treat them like analytics. Name a category in monitoring to hold monitoring back until it is granted:
// config/consent.ts
import { defineConfig } from '@kitr/consent'
export default defineConfig({
categories: ['analytics', 'marketing'],
version: 1,
analytics: 'analytics',
monitoring: 'analytics',
})
While the category is not granted:
captureException,captureMessage,addBreadcrumbandsetTagare dropped, not queued. Errors from before consent are never reported.setUser(user)is remembered but not sent. When the visitor grants the category, the remembered user is sent at once, so you never callsetUser()twice.- When the visitor withdraws, monitoring receives
setUser(null)and drops reports again.
The default, monitoring: false, leaves monitoring alone: errors are reported whatever the visitor chose. Many teams treat crash reports as necessary for running the service; decide with whoever owns your privacy policy.
Read the choice outside React
Outside components, read the service from the container. It has the same actions as the hook, plus subscribe() for changes:
// app/lib/consent/track-choices.ts
import type { KitrApp } from 'kitr/app'
export function logConsentChanges(app: KitrApp) {
const consent = app.container.use('consent')
console.info('consent at start', consent.get())
return consent.subscribe((record) => {
console.info('consent changed', record.granted)
// => consent changed ['necessary', 'analytics']
})
}
| Member | Returns | Description |
|---|---|---|
get() |
ConsentRecord |
The current record. The same object until the choice changes, so it is safe in useSyncExternalStore. |
decided() |
boolean |
Whether the visitor chose, for the current version. |
has(category) |
boolean |
Whether a category is granted. Always true for necessary. |
grant(categories) |
void |
Record exactly these categories plus necessary. |
acceptAll() |
void |
Grant every configured category. |
rejectAll() |
void |
Grant only necessary. |
reset() |
void |
Forget the choice and delete the cookie. |
subscribe(listener) |
() => void |
Call listener(record) after every grant, acceptAll, rejectAll and reset on this service. Returns the unsubscribe function. |
categories |
ConsentCategory[] |
The configured categories. |
cookie |
CookieItem<ConsentRecord> |
The storage cookie item that holds the record. |
@kitr/consent also exports a useConsent(app) function that returns this service. Despite the name it is not a hook; in components use useConsent() from @kitr/consent/react.
Read consent on the server
The record is a cookie, so a server function or loader can read it from the request with consent.cookie.get(headers). Use it to leave a marketing tag out of the HTML entirely for visitors who did not agree:
// app/lib/consent/read-consent.ts
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { createConsent } from '@kitr/consent'
import consentConfig from '#config/consent'
const { cookie } = createConsent(consentConfig)
/** Runs on the server: the categories this visitor granted, or none if they have not chosen. */
export const readConsent = createServerFn({ method: 'GET' }).handler(() => {
const record = cookie.get(getRequest().headers)
const current = record.version === (consentConfig.version ?? 1) && Array.isArray(record.granted)
return current && record.decidedAt ? record.granted : []
})
// => ['necessary', 'analytics'] for Ada, [] for a first visit
cookie.get() returns the stored record as it is, so check version yourself, as above. The cookie name includes the storage prefix, which the storage provider sets on the server too, so the same config/storage.ts applies to both sides.
Testing
Two things are worth testing: that your banner records the right choice, and that tracking waits for it. The test app needs environment: 'client', because the provider only gates analytics in the browser. The DOM environment keeps cookies between tests, so clear them in afterEach.
// app/components/consent-banner.test.tsx
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { memoryAdapter } from '@kitr/analytics'
import { analyticsProvider } from '@kitr/analytics/provider'
import { consentProvider } from '@kitr/consent/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ConsentBanner } from './consent-banner'
afterEach(() => {
document.cookie = 'consent=; max-age=0; path=/'
})
describe('ConsentBanner', () => {
it('holds analytics until Ada accepts', async () => {
const memory = memoryAdapter()
const app = await createTestApp({
environment: 'client',
providers: [analyticsProvider, consentProvider],
config: {
analytics: { adapters: [memory], enabled: false, pageViews: false },
consent: { categories: ['analytics', 'marketing'] },
},
})
const analytics = app.container.use('analytics')
renderWithKitr(<ConsentBanner />, { app })
analytics.track('project.created', { id: 'prj_42' })
expect(memory.calls).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Accept all' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(app.container.use('consent').get().granted).toEqual(['necessary', 'analytics', 'marketing'])
await waitFor(() =>
expect(memory.calls).toEqual([{ type: 'track', args: ['project.created', { id: 'prj_42' }] }]),
)
})
it('sends nothing when Ada picks only necessary', async () => {
const memory = memoryAdapter()
const app = await createTestApp({
environment: 'client',
providers: [analyticsProvider, consentProvider],
config: { analytics: { adapters: [memory], enabled: false, pageViews: false } },
})
renderWithKitr(<ConsentBanner />, { app })
fireEvent.click(screen.getByRole('button', { name: 'Only necessary' }))
app.container.use('analytics').track('project.created', { id: 'prj_42' })
expect(app.container.use('consent').has('analytics')).toBe(false)
expect(app.container.use('analytics').enabled).toBe(false)
expect(memory.calls).toEqual([])
})
})
The test uses the generated banner. If you added the mount guard from Show the banner without a flash, renderWithKitr runs the effect for you, so the same test passes.
For logic that only needs the record, createConsent() builds a service with no app. It reads and writes document.cookie, so it behaves exactly as in the browser:
// app/lib/consent/consent.test.ts
import { createConsent } from '@kitr/consent'
import { afterEach, expect, it } from 'vitest'
afterEach(() => {
document.cookie = 'consent=; max-age=0; path=/'
})
it('asks again after the version is bumped', () => {
createConsent({ version: 1 }).acceptAll()
expect(createConsent({ version: 1 }).decided()).toBe(true)
expect(createConsent({ version: 2 }).decided()).toBe(false)
})
Run tests that touch document in a DOM environment (happy-dom or jsdom).
Devtools
With devtools installed, every choice is logged under the consent channel as decided, with the record as data. Filter the Log panel by consent to see what was granted and when. The Analytics panel shows whether analytics is enabled or still queued, and its Enable and Disable actions let you check both states without clearing cookies.
Next steps
- API reference for every export and type.
- Analytics for what is held until consent.
- Monitoring for the error reports you can gate.
- Storage for the cookie the record lives in.
- European Data Protection Board guidelines on consent for what a valid choice requires.