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
- Policy, categories, and legal links live in
apps/web/c15t.config.ts. The provider inconsent.tsxresolves that config on the server. - Leave
NEXT_PUBLIC_C15T_BACKEND_URLunset for offline regional rules. Set it for a hosted consent.io or self-hosted backend. - Analytics call
useConsent("measurement")and pass that boolean into@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_BACKEND_URL unset | Decisions stay on the device. EU opt-in, California opt-out, and no banner for the rest of the world |
| Hosted | NEXT_PUBLIC_C15T_BACKEND_URL set to your c15t or consent.io backend | Consent is stored on the backend so you have an audit trail and the same decision across devices |
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 consent setup
Categories, legal links, translations, and the offline or hosted mode live in apps/web/c15t.config.ts. next.config.ts wraps the Next.js config with withConsentManifest from @c15t/nextjs/build so that file is part of the build.
Offline mode is the default. Hosted mode turns on when NEXT_PUBLIC_C15T_BACKEND_URL is set:
mode: hasHostedBackend
? undefined
: offline({
policyRules: [
policyRulePresets.europeOptIn(),
policyRulePresets.californiaOptOut(),
policyRulePresets.worldOptOutNoPrompt(),
],
}),The provider resolves that config on the server and passes the active locale into the banner:
<ConsentRoot
state={resolveConsent({ language: locale })}
options={{
i18n: {
locale,
detectBrowserLanguage: false,
},
}}
>
{children}
<ConsentBanner
hideBranding
legalLinks={["privacyPolicy", "cookiePolicy"]}
layout={["customize", ["reject", "accept"]]}
/>
<ConsentDialog hideBranding legalLinks={["privacyPolicy", "cookiePolicy"]} />
</ConsentRoot>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_BACKEND_URL, the config uses offline mode and the three policy rules above. That matches typical GDPR (opt-in), CCPA (opt-out), and no banner elsewhere, 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_BACKEND_URL="https://your-instance.c15t.dev"The value is validated as an optional URL in apps/web/env.config.ts. When it is present, c15t.config.ts leaves mode unset so c15t uses that backend.
Proxy the backend (optional)
For production, you can proxy the backend through your app so the browser never talks to the raw vendor host. Point NEXT_PUBLIC_C15T_BACKEND_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.
The hrefs are set in c15t.config.ts under options.legalLinks. Keep them aligned with the legal pages routes, including any locale prefix your app uses.
Gate analytics on measurement consent
AnalyticsProvider already checks the measurement category before identifying users or enabling the analytics Provider:
const enabled = useConsent("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 useConsent("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
TurboStarter maps c15t CSS variables to your theme tokens on html:root[data-theme] in apps/web/src/assets/styles/globals.css (--c15t-primary follows --primary, along with surfaces and radii). Change the design system theme and the banner follows.
Banner width is set under options.theme in c15t.config.ts. hideBranding is already on the banner and dialog so the vendor badge stays off.
Locales
Consent copy uses @c15t/translations. The config imports English and Spanish messages and registers them on options.i18n.messages. When you add a locale to @workspace/i18n, add the matching c15t messages object in that 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 the offline policy rules (see c15t policy rules) to verify EU opt-in versus California opt-out without a VPN.
Verify the matrix
| Scenario | Expected |
|---|---|
| Fresh visit from an EU region (offline rules) | Banner shown; analytics off until Accept (or Customize, then enable measurement) |
| Reject all | useConsent("measurement") is false; analytics Provider is disabled and reset |
| Accept all | Analytics initializes; identify runs for signed-in users |
| Footer, Cookie preferences | Dialog opens; changing measurement toggles analytics on or off |
NEXT_PUBLIC_C15T_BACKEND_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 worldOptOutNoPrompt 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-* bridge in globals.css; theme attribute on html |
| Hosted mode errors | Valid NEXT_PUBLIC_C15T_BACKEND_URL, CORS or 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 and pixels gated with
useConsent(...)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