---
title: Rendering and deployment
description: How c15t resolves consent in client-rendered React apps, React
  Router framework mode, Remix and other server-rendered React apps without a
  c15t adapter.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## 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 app|Where consent resolves|Setup|
|--|--|--|
|Vite, or another client-rendered single-page app|Browser, after the bundle loads|[Quickstart](/docs/frameworks/react/quickstart)|
|Prerendered or static HTML|Browser, after the page loads|[Quickstart](/docs/frameworks/react/quickstart), see [static pages](#static-and-prerendered-pages)|
|React Router framework mode, Remix, or another server-rendered React app|Browser, after hydration|[Quickstart](/docs/frameworks/react/quickstart), see [server-rendered apps](#react-router-remix-and-other-server-rendered-apps)|
|Next.js or TanStack Start|Server, per request|That framework's guide in the [framework list](/docs/frameworks)|

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](/docs/concepts/choose-your-setup#server-rendering-or-browser-rendering)
explains the difference in general terms.

## Bundle the manifest during Vite builds

The [quickstart](/docs/frameworks/react/quickstart) and `examples/react` in
the c15t repository use this setup.

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

Add `consentManifest` to your existing Vite plugin list:

```ts title="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`:

```tsx title="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. `consentManifest` warns
during the build when it downloads such a policy, and suggests `hosted()`. That response can use
newer backend policy than the build snapshot. This is a full consent resolution,
so these visitors still wait for a backend round trip. `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. To skip the
round trip for location-specific rules, pass the country your CDN or edge
already knows through `inputs`.

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](/docs/concepts/modes#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:

|Command|Default when the fetch fails|
|--|--|
|Production build: `next build`, `vite build`, `nuxt build`, `astro build`|The build stops with an error.|
|Dev: `next dev`, `vite dev`, `nuxt dev`, `astro dev`|A 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:

```sh
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](/docs/concepts/modes#what-happens-when-the-download-fails)
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.

For policy updates without a rebuild, use `hosted({ backendURL })` from `c15t/react`
as the mode and omit the build plugin. That setup resolves policy through the
backend `/init` on each page load.

## Client-rendered apps

The recommended [quickstart](/docs/frameworks/react/quickstart) bundles the
manifest and resolves it in the browser. See [build-time manifests](#bundle-the-manifest-during-vite-builds).

For policy updates without rebuilding, use `hosted()` from `c15t/react` as
the mode. It reads the backend URL `consentManifest` read from
`VITE_C15T_BACKEND_URL` or `VITE_INTH_PROJECT_URL`; without the plugin, pass
`hosted({ backendURL })`.
That runtime path works as follows:

1. The page loads with no consent state. Every optional category is denied.
2. `ConsentProvider` requests `GET /init` from your backend URL while it first
   renders, before the page paints. The backend picks the visitor's policy
   from their location. With an `experiment`, the request waits until the
   provider mounts, because it carries the visitor's arm.
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.

## 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 resolves the bundled manifest, then shows the
  banner if the policy asks for one. When the banner depends on a country or
  region the browser does not know, it calls `/init` and uses the backend's
  current policy and translations, which can differ from the build snapshot.
  With `hosted()`, it always calls `/init`.
* 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.

### 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 build-time snapshot. Consent saves and
any `/init` requests 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](/docs/concepts/data-fetching) covers what is safe to
cache.

## Where the browser sends requests

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

|Request|When|
|--|--|
|`GET {url}/init`|Once per page load, to resolve the policy|
|`POST {url}/subjects`|When the visitor accepts, rejects or saves|

With a bundled manifest, `manifest()` sends the same
`/subjects` request, and requests `/init` only when the policy depends on a
country or region the browser does not know.

These requests go to the backend's origin, so it must list your site as a
trusted origin.
[Data fetching](/docs/concepts/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 there is no `/manifest` request with a
   build-time snapshot. A regional policy may still need an `/init` request
   to resolve consent when geography is missing. With `hosted()`, 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.
