Skip to main content

React

Rendering and deployment

Pick your rendering path

c15t/react resolves consent in the browser. That works for every React app, including server-rendered ones. What changes is what the first HTML contains.

Your appWhere consent resolvesSetup
Vite, or another client-rendered single-page appBrowser, after the bundle loadsQuickstart
Prerendered or static HTMLBrowser, after the page loadsQuickstart, see static pages
React Router framework mode, Remix, or another server-rendered React appBrowser, after hydrationQuickstart, see server-rendered apps
Next.js or TanStack StartServer, per requestThat framework's guide in the framework list

Pick the Next.js or TanStack Start adapter if you use those frameworks. They read the visitor's cookie and location on the server, so the banner can be in the first HTML. Choose your setup explains the difference in general terms.

Client-rendered apps

The quickstart and examples/react in the c15t repository use hosted() from c15t/react. It reads the backend URL consentManifest read from VITE_C15T_BACKEND_URL or VITE_INTH_PROJECT_URL; without the plugin, pass hosted({ backendURL }). Policy edits apply on the next page load, without a rebuild. The page loads like this:

  1. The page loads with no consent state. Every optional category is denied.
  2. The browser requests GET /init from your backend URL. A script in index.html sends it while the JavaScript downloads. The backend picks the visitor's policy from their location. With an experiment, pass it to the plugin as initPrefetch: { experiment } so the early request carries the visitor's arm. Without that, the provider sends its own /init when it mounts.
  3. The provider applies the policy and any stored choice. It shows the banner if the policy asks for one, and loads the scripts the visitor allowed.

Until step 3 finishes, gated scripts and ConsentGate embeds stay blocked. If the backend request fails, they stay blocked and no banner shows.

Send /init before the app loads

consentManifest() adds a small inline script to the <head> of index.html. It sends /init while the browser downloads your JavaScript, and hosted() uses that response instead of sending its own. The banner usually shows 50 to 250 ms sooner. There is nothing to configure.

The early request can't read your app's options, so in a few setups it won't match and the app sends a second /init. Pass initPrefetch: false when the app uses offline() or gives hosted() its own backendURL, fetch, initURL or headers. With its own fetch, hosted() sends /init through it and ignores the early response. If it sets credentials, overrides, storageKey, an experiment, or journey: false, pass the same values as initPrefetch: { credentials, experiment, journey, overrides, storageKey }. With journey: 'tab', passing it too makes a tab's first /init report the 'tab' scope instead of 'page'. It doesn't save a request. Pass experiment only when c15t assigns the arm. If a feature flag picks it, leave it out, or the script sends an arm the visitor may never see. The dev server adds the script even to a manifest() app, which then sends one /init per page load that it doesn't use. A production build leaves it out.

React Router, Remix and other server-rendered apps

React Router framework mode, Remix and similar frameworks have no c15t adapter. Use the quickstart's Consent component and render it in your root component, around the outlet. In React Router framework mode that is app/root.tsx.

ConsentProvider renders on the server without touching browser APIs, and the browser takes over after hydration:

  • The server HTML contains your page but no banner and no consent state.
  • After hydration the browser calls /init, then shows the banner if the policy asks for one.
  • The server render and the first browser render agree, so hydration does not warn or change a stored choice.

The Vite setup needs no change. The banner renders in the browser and brings its own <style> element, so there is no stylesheet to import.

These frameworks render their own HTML, so the Vite plugin can't add the early /init script. Put buildPrefetchScript({ backendURL }) from c15t in a <script> in the root <head> instead, unless your loader supplies initialData as described below. The two are alternatives: with initialData the provider never reads the script's response.

Skip the browser's first init request

This is optional. fetchSSRData from c15t/react/server calls the backend's /init from your server loader, forwarding the visitor's location and language headers. Pass the result to the browser, wrap it in a promise and give it to hosted({ backendURL, initialData }). The provider's first init then uses that response instead of a network request.

Know its limits before you add it:

  • It saves the browser's round trip to the backend. It does not put the banner in the server HTML; the banner still mounts after hydration.
  • Every page view makes a server-to-server /init request. Do not cache the loader response, because it contains one visitor's policy.
  • It returns undefined when the request has no location or language headers, and the browser then requests /init as usual.
  • mode is read once, when the provider mounts. Create the hosted() mode once in the browser rather than on every render.
  • A relative backendURL resolves against the request's host header, over https for a domain name and over http for localhost, an IP address or a single-label host. fetchSSRData ignores x-forwarded-host, x-forwarded-proto and forwarded, and does not send them to the backend. Behind a proxy that overwrites those headers, pass trustForwardedHeaders: true to use them.
  • If your server answers requests for any Host header, such as a Node server exposed directly or a proxy that forwards Host from its default virtual host, pass fetchSSRData an absolute backendURL. A relative one resolves against whatever Host the request sent.

Render the banner in the server HTML

c15t has no framework-neutral helper for this. ConsentProvider accepts a server-resolved prefetch state, but the Next.js and TanStack Start adapters build that state from the request's cookie, headers and a cached policy manifest. Building it by hand ties your app to internal shapes. If the banner must be part of the first paint, use one of those adapters. Otherwise keep the browser path.

Static and prerendered pages

A prerendered page is the same HTML for every visitor, so it cannot contain one visitor's consent. Use the quickstart's setup. /init requests and consent saves go to the backend URL directly, so the static host needs no server route. Add the site's origin to the trusted origins in your Inth project.

Do not cache a response that contains a visitor's consent in a CDN or a shared page cache. Data fetching covers what is safe to cache.

Where the browser sends requests

With hosted(), the browser calls two backend endpoints:

RequestWhen
GET {url}/initOnce per page load, to resolve the policy
POST {url}/subjectsWhen the visitor accepts, rejects or saves

These requests go to the backend's origin, so it must list your site as a trusted origin. Data fetching compares this with proxies and custom transports.

Check the rendering path

  1. Open the page in a private window and view the page source. In a server-rendered app the HTML contains your content and no element with data-testid="consent-banner-root".
  2. In DevTools Network, confirm one /init request runs, unless you supply initialData.
  3. The banner appears once the policy resolves. Vendor requests stay absent until you allow their category.

Bundle the policy at build time

manifest() resolves the policy in the browser from a snapshot the build downloaded. It saves the /init request only in these cases:

  • The policy has no location rules, or every location gets the same banner.
  • The page already knows the visitor's location, for example from an edge worker, and passes it as inputs.
  • You run a same-origin geoURL route that returns the location.

Otherwise the browser still calls /init on a first visit and uses the backend's answer, so the bundle adds bytes without saving a request. consentManifest warns during the build when that happens. The resolver adds about 3 KB gzipped to the bundle, and policy edits need a rebuild. The browser sends no /init, so the backend doesn't count visitors it resolves this way.

The build fetches your public policy once and bundles it, so the server never fetches it at runtime.

Keep consentManifest in your Vite plugin list:

vite.config.ts (plugins option)
import { consentManifest } from 'c15t/build';

export default {
	plugins: [
		// Keep your existing framework plugins here.
		consentManifest({ backendURL: 'https://your-project.inth.app' }),
	],
};

Without backendURL, the plugin reads VITE_C15T_BACKEND_URL, then VITE_INTH_PROJECT_URL. The plugin fetches the policy during vite build, or in vite dev when the app first loads it, and serves it as snapshot from c15t/generated. A build fetches only when the app uses manifest(). In src/consent.tsx, create the provider's mode with manifest() from c15t/react instead of hosted(). It reads the snapshot and the backend URL from c15t/generated:

src/consent.tsx
import { manifest } from 'c15t/react';

const mode = manifest();

Keep passing mode to ConsentProvider. The browser resolves the snapshot without /manifest when it has the geography the policy needs, or the policy does not depend on geography. Supply known country and region through inputs or overrides to keep regional resolution on the snapshot.

When the build has no snapshot, as in vite dev after a failed fetch, snapshot is undefined, and the browser fetches ${backendURL}/manifest instead.

When the required country or region is missing, the transport calls backend /init and uses its resolved policy and translations. That response can use newer backend policy than the build snapshot. ConsentProvider sends that request while it first renders, before the page mounts, unless the visitor has a stored choice.

The browser cannot guess the banner from the snapshot alone when locations differ. With a European opt-in rule and no banner elsewhere, a banner shown before /init answers would vanish for most of the world. The exception is a policy whose country and region rules all show the same banner, with a default rule that covers everywhere else: the browser resolves it at once.

Policy and resolver code are part of the browser bundle.

The snapshot is fixed at build time:

  • Rebuild after changing policies, translations or vendors. If your CI caches build output, force a fresh build.
  • Consent choices still go to the backend.

The build reads the backend URL from your public backend URL variable when the config doesn't pass one: NEXT_PUBLIC_C15T_BACKEND_URL in Next.js, NUXT_PUBLIC_C15T_BACKEND_URL in Nuxt, PUBLIC_C15T_BACKEND_URL in Astro, Svelte and SvelteKit, and VITE_C15T_BACKEND_URL in TanStack Start and other Vite apps. Each also reads the matching Inth variable, such as NEXT_PUBLIC_INTH_PROJECT_URL, when the c15t one is unset. See set the backend URL.

The fetch waits at most 10 seconds. When it fails, or no backend URL is set, every framework does the same thing:

CommandDefault when the fetch fails
Production build: next build, vite build, nuxt build, astro buildThe build stops with an error.
Dev: next dev, vite dev, nuxt dev, astro devA warning, and the server fetches the policy at runtime.

Set onBuildError to use one behaviour for both. 'fail' stops dev too. 'runtime' lets a production build finish, and the server fetches the policy at runtime. The C15T_ON_BUILD_ERROR environment variable overrides the option, so you can deploy during a backend outage without a code change:

C15T_ON_BUILD_ERROR=runtime npm run build

Turborepo's strict environment mode hides undeclared variables from tasks, so list C15T_ON_BUILD_ERROR in the build task's passThroughEnv there.

The build skips the fetch, without an error, when it can't use a snapshot, for example when the backend URL is relative. With onBuildError: 'fail', a relative URL stops the build. Consent modes lists every case.

vite build and vite dev fetch the manifest when they start. vite preview serves the last build without fetching. The plugin writes no file into your app, so there is nothing to keep out of Git. c15t/generated ships its own types, so tsc, vue-tsc and svelte-check pass on a fresh checkout without a build first.

The plugin can't see the options your app passes to manifest(). When the app passes source: 'runtime' or manifestURL, set consentManifest({ source: 'runtime' }) too. The build then downloads no manifest, so it doesn't fail when the backend's /manifest is down.