For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Cookie consent
Configure TurboStarter web cookie consent with c15t, choose offline or hosted mode, and gate analytics on measurement consent.
TurboStarter ships cookie consent for the web app with c15t. The banner and preferences dialog are already wired into the provider tree, themed to your design tokens, and connected so analytics only run after the user grants measurement consent.
You do not need to install another CMP for the happy path - customize categories, legal links, and storage mode in one place.
TL;DR
- Consent lives in
apps/web/src/lib/providers/consent.tsxand wraps analytics inproviders.tsx. - Leave
NEXT_PUBLIC_C15T_URLunset for offline regional policy packs; set it for hosted consent.io / self-hosted backend. - Analytics call
has("measurement")and passenabledinto@workspace/analytics-webProvider. - Users reopen preferences from the footer via
ConsentDialogLink. - Keep privacy and cookie policy pages filled - the banner links to them.
Offline vs hosted
| Mode | When | Behavior |
|---|---|---|
| Offline (default) | NEXT_PUBLIC_C15T_URL unset | Client-side storage + regional policy packs: EU opt-in, California opt-out, rest of world no banner |
| Hosted | NEXT_PUBLIC_C15T_URL set to your c15t / consent.io backend | Consent persisted on the backend for audits and multi-device consistency |
Use offline for local development and simple sites. Prefer hosted when you need a durable audit trail or a managed geo policy from c15t.
Inspect the ConsentProvider
The kit wraps the app with ConsentManagerProvider, renders the banner and dialog, and passes the active locale into c15t i18n:
<ConsentManagerProvider
options={{
...(env.NEXT_PUBLIC_C15T_URL
? { mode: "hosted" as const, backendURL: env.NEXT_PUBLIC_C15T_URL }
: {
mode: "offline" as const,
offlinePolicy: {
policyPacks: [
policyPackPresets.europeOptIn(),
policyPackPresets.californiaOptOut(),
policyPackPresets.worldNoBanner(),
],
},
}),
consentCategories: ["necessary", "measurement"],
legalLinks: {
privacyPolicy: {
href: pathsConfig.marketing.legal("privacy-policy"),
target: "_self",
},
cookiePolicy: {
href: pathsConfig.marketing.legal("cookie-policy"),
target: "_self",
},
},
// i18n + theme…
}}
>
<ConsentBanner
hideBranding
legalLinks={["privacyPolicy", "cookiePolicy"]}
layout={["customize", ["reject", "accept"]]}
/>
<ConsentDialog hideBranding legalLinks={["privacyPolicy", "cookiePolicy"]} />
{children}
</ConsentManagerProvider>ConsentProvider sits outside AnalyticsProvider in providers.tsx so analytics can read consent state on first paint.
Choose offline or hosted storage
Offline (default)
Do nothing. Without NEXT_PUBLIC_C15T_URL, the provider uses offline mode and the three policy packs above. That matches typical GDPR (opt-in), CCPA (opt-out), and “no banner elsewhere” expectations without a backend.
Hosted
Create a consent.io (or self-hosted c15t) instance, then set the public backend URL in apps/web/.env.local and your deployment env:
NEXT_PUBLIC_C15T_URL="https://your-instance.c15t.dev"The value is validated as an optional URL in apps/web/env.config.ts. When it is present, the provider switches to mode: "hosted" and uses that URL as backendURL.
Proxy the backend (optional)
For production, c15t recommends proxying the backend through your app (for example a Next.js rewrite to /api/c15t) so the browser never talks to the raw vendor host. Point NEXT_PUBLIC_C15T_URL at that same-origin path when you add the rewrite.
Keep legal pages linked
The banner and dialog link to:
/legal/privacy-policy/legal/cookie-policy
Those routes come from the legal pages CMS collection. Fill the MDX content before launch - empty policies with live consent UI still look unfinished to users and reviewers.
Paths are built with pathsConfig.marketing.legal(...) so locale prefixes stay correct.
Gate analytics on measurement consent
AnalyticsProvider already checks the measurement category before identifying users or enabling the analytics Provider:
const { has } = useConsentManager();
const enabled = has("measurement");
useEffect(() => {
if (session.isPending) {
return;
}
if (!enabled) {
return reset();
}
if (session.data?.user) {
const { id, email, name } = session.data.user;
identify(id, { email, name });
} else {
reset();
}
}, [session, enabled]);
return <Provider enabled={enabled}>{children}</Provider>;Each web analytics strategy honors enabled (for example PostHog opts out of capturing when it is false). When you add a custom analytics provider, accept enabled?: boolean on Provider and skip init / page views when it is false - see analytics tracking.
If you introduce marketing pixels later, add a marketing category to consentCategories and gate those scripts with has("marketing") the same way.
Let users reopen preferences
The marketing footer includes a Cookie preferences entry that opens the consent dialog without a separate route:
{
title: "legal.cookies",
component: ConsentDialogLink,
},Copy lives under common:legal.cookies (Cookie preferences in English). Keep that link in any custom footer so users can revoke or change consent after the first choice.
Customize branding, locale, and categories
Theme
c15t styles import from @c15t/nextjs/styles.css. TurboStarter maps c15t CSS variables to your theme tokens on html:root[data-theme] in apps/web/src/assets/styles/globals.css (--c15t-primary → --primary, surfaces, radii, and so on). Change the design system theme and the banner follows.
Action button variants and banner max-width are set under options.theme in consent.tsx. hideBranding is already on so the vendor badge stays off.
Locales
Consent copy uses @c15t/translations for the locales you register. The kit maps Locale.EN / Locale.ES to baseTranslations. When you add a locale to @workspace/i18n, add the matching c15t messages object in the same i18n.messages map.
detectBrowserLanguage is false so the UI locale stays tied to the Next.js [locale] segment.
Categories
Default:
consentCategories: ["necessary", "measurement"];Extend only when you actually load scripts in that bucket (marketing, functional, and so on). Updating the array without gating new scripts leaves toggles that do nothing.
Preview a region offline
During local testing you can force a country override in offline policy options (see c15t policy packs) to verify EU opt-in vs California opt-out without a VPN.
Verify the matrix
| Scenario | Expected |
|---|---|
| Fresh visit from an EU region (offline packs) | Banner shown; analytics off until Accept (or Customize → enable measurement) |
| Reject all | has("measurement") false; analytics Provider disabled / reset |
| Accept all | Analytics initializes; identify runs for signed-in users |
| Footer → Cookie preferences | Dialog opens; changing measurement toggles analytics on/off |
NEXT_PUBLIC_C15T_URL set | Hosted mode; decisions go to your c15t backend |
| Legal links in banner | Navigate to privacy and cookie policy pages |
Also confirm in DevTools that your analytics network calls only appear after measurement consent.
Troubleshooting
| Symptom | What to check |
|---|---|
| Banner never appears | Offline worldNoBanner for non-EU/CA geos; hosted geo rules; browser already stored a prior decision - clear site data |
| Analytics still fire after reject | Confirm ConsentProvider wraps AnalyticsProvider; custom providers must respect enabled |
| Banner looks unstyled / wrong colors | @c15t/nextjs/styles.css import and --c15t-* bridge in globals.css; theme attribute on html |
| Hosted mode errors | Valid NEXT_PUBLIC_C15T_URL, CORS / rewrite to the backend, env present in the client build |
| Wrong language in dialog | Locale passed into ConsentProvider; messages map includes that Locale key |
Checklist
- Privacy and cookie policy MDX filled in
- Offline packs acceptable for your markets, or hosted URL configured
- Only real tracking categories listed in
consentCategories - New analytics / pixels gated with
has(...)orenabled - Footer (or settings) still exposes cookie preferences
- Spot-check Accept / Reject / Customize in the regions you care about
How is this guide?
Last updated on