---
title: Performance
description: Bundle policy at build time to avoid manifest fetch latency, use
  runtime caching when policies must refresh without a rebuild, and optionally
  keep browser consent requests on your origin.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Bundle the manifest during builds

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

Wrap your Next.js config in `withConsentManifest`. An existing config object
or function goes in as the first argument, unchanged:

```ts title="next.config.ts"
import { withConsentManifest } from 'c15t/next/build';
import type { NextConfig } from 'next';

const nextConfig = {} satisfies NextConfig;

// Reads NEXT_PUBLIC_C15T_BACKEND_URL, like defineConsentConfig.
export default withConsentManifest(nextConfig);
```

`next build`, `next dev` and `next typegen` fetch `${backendURL}/manifest` and
write the policy to `node_modules/.cache/c15t/`, outside your source tree, so
there is nothing to keep out of Git. `next start` serves the built snapshot and
fetches no policy. Without a `backendURL` option, the build reads the
`backendURL` in `c15t.config.ts`, which defaults to
`NEXT_PUBLIC_C15T_BACKEND_URL`, then `NEXT_PUBLIC_INTH_PROJECT_URL`, so saves
and the snapshot point at the same
project.

The wrapper reads `c15t.config.ts` the way Next.js reads `next.config.ts`, and
skips the fetch for `mode: hosted()`, `mode: offline()`,
`manifest({ snapshot })` and `manifest({ source: 'runtime' })`, which read no
build-time manifest. Those builds never contact the backend. If the file can't
be read at build time, for example because it imports something Node can't
load, the build warns and fetches as for `manifest()`.

The wrapper also finds `c15t.config.ts` (or `.mts`, `.js`, `.mjs`) at the
project root, next to `next.config.ts`, and points `c15t/generated` at the
cache, in Turbopack and webpack. `ConsentRoot`, `resolveConsent` and the
consent route read both without an import. Server code that needs the
snapshot itself imports `snapshot` from `c15t/generated`. Only server
bundles get the snapshot: in a browser bundle,
`c15t/generated` imports `server-only`, so importing it from a client
component fails the build. The wrapper also adds `c15t`, `@c15t/core` and
`@c15t/nextjs` to `transpilePackages`, so Pages Router server bundles see the
aliases. A package you list in `serverExternalPackages` stays external: it
reads no `c15t.config.ts` and fetches the policy at runtime.

If the fetch fails, `next build` stops. `next dev` logs a warning instead, and
`snapshot` is `undefined`, so the server fetches and caches the policy at
runtime, as with
[runtime manifest caching](/docs/frameworks/next/optimization#reuse-cached-policy-data).
To let a build continue the same way, set `onBuildError: 'runtime'`, or run it
with `C15T_ON_BUILD_ERROR=runtime`:

```ts title="next.config.ts"
export default withConsentManifest(nextConfig, { onBuildError: 'runtime' });
```

A missing backend URL counts as a failed fetch. The build skips the fetch and
leaves `snapshot` `undefined` when the backend URL is relative, or when the
config sets `output: 'export'`, which has no server to use the snapshot. With
`onBuildError: 'fail'`, a relative URL stops the build. A static export always
skips the fetch.

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.

To apply policy edits without a rebuild, set
`mode: manifest({ source: 'runtime' })` in `c15t.config.ts`. The server then
uses [runtime manifest caching](/docs/frameworks/next/optimization#reuse-cached-policy-data).
Keep `withConsentManifest`, which also finds `c15t.config.ts`.

## Reuse cached policy data

Use runtime manifest caching when policy changes must reach your app without
a rebuild. Set the mode in `c15t.config.ts`:

```ts title="c15t.config.ts"
import { defineConsentConfig, manifest } from 'c15t/next';

export default defineConsentConfig({ mode: manifest({ source: 'runtime' }) });
```

Keep `withConsentManifest`, which finds the config. A manifest contains public
policy configuration that your app server can reuse across requests. Each
visitor's consent still resolves separately.

A warm cache avoids repeated backend policy resolution. It does not eliminate
all requests. Your app may serve its consent route, cold caches fetch
upstream data, and consent choices still reach Inth. IAB policies can also need
a Global Vendor List fetch.

Server rendering supplies the initial policy to the browser. For browser
initialization that needs request geography, add the
[consent route](/docs/frameworks/next/data-fetching-reference#do-i-need-the-consent-route).

## Compare manifests with backend initialization

A warm manifest cache can reduce initialization latency by avoiding an upstream
backend request. The saving depends on backend latency, cache hits and where
consent resolution sits in rendering. It is not a fixed speedup for the whole
page.

|Fetching path|Work during initialization|
|--|--|
|Build-time manifest, recommended|Resolve consent locally from the deployment's snapshot, including on a fresh server instance.|
|Regular backend `/init`|Wait for the backend to resolve consent and return the result.|
|Cold manifest cache|Fetch public policy from the backend, cache it and resolve consent locally.|
|Warm manifest cache|Reuse cached policy and resolve consent locally; local route requests and rendering still take time.|

### Measured performance

Two benchmark reports measure these setups. Each report lists its machine,
network conditions, samples and reproduction steps.

* [Manifest versus backend init](https://github.com/c15t/c15t/tree/v3/benchmarks/reports/next-manifest-2026-09-10).
  With 150 ms of simulated backend latency, a warm manifest cache cut the
  median server response time for a new visitor from about 158 ms to about
  7 ms. A cold cache still waits for the upstream fetch.
* [Streamed versus awaited layout](https://github.com/c15t/c15t/tree/v3/benchmarks/reports/next-consent-boundary-2026-09-25).
  With a 4× CPU slowdown, the streamed layout painted the page in about
  90 ms and the awaited layout in about 390 ms, because React held the
  awaited page's streamed reveal.

Measure your own deployment before you choose a layout for speed.

## Keep browser consent requests on your origin

By default the browser saves consent to `${backendURL}/subjects`, a separate
origin. Sending those requests through your own app can avoid a separate
browser DNS lookup and TLS connection to the consent backend, and lets a
`connect-src 'self'` policy cover them. Manifests and server rendering work
without it.

The app server still connects to the backend, and each save takes an extra
hop through your server. Measure your deployment to check the effect on
latency. Vendor scripts, pixels and iframes keep their own URLs; only c15t
requests move. The same setup works with a self-hosted c15t backend.

Set `routePrefix` and `proxy: true` in the config, and keep `backendURL`
absolute:

```ts title="c15t.config.ts"
import { defineConsentConfig } from 'c15t/next';

export default defineConsentConfig({ proxy: true, routePrefix: '/api/c15t' });
```

Then mount the consent route with `proxy: true`, so it forwards saves to the
backend. In the App Router:

```ts title="app/api/c15t/[...c15t]/route.ts"
import { createConsentRoute } from 'c15t/next/api';

export const { DELETE, GET, OPTIONS, PATCH, POST, PUT } = createConsentRoute({
	proxy: true,
});
```

In the Pages Router:

```ts title="pages/api/c15t/[...c15t].ts"
import { createPagesConsentRoute } from 'c15t/next/pages';

export default createPagesConsentRoute({ proxy: true });
```

What the route serves:

|Request|Handled by|
|--|--|
|`GET /api/c15t/init`|The route, from the bundled manifest, for this request's location, language and privacy signal|
|`GET /api/c15t/manifest`|The route, serving the bundled manifest|
|`POST /api/c15t/subjects` and other consent paths|Forwarded to your backend because `proxy: true`|
|Any other path|`404`, so the route is never an open proxy|

Keep these details right:

* `proxy: true` in `c15t.config.ts` points the browser at `routePrefix` for
  init and saves. `resolveConsent`, `withConsentProps` and the consent route
  keep the absolute `backendURL` from the config or
  `NEXT_PUBLIC_C15T_BACKEND_URL`, so the server never fetches its own route
  and `withConsentManifest` still downloads the manifest from the backend.
* `proxy` needs `routePrefix`. Without it, `defineConsentConfig` throws
  `` @c15t/nextjs: `proxy` sends saves through the consent route, so it needs `routePrefix`. ``
* Set `proxy: true` in both places. With it only in the config, saves reach a
  route that answers them with 404 or 405. To send only init through the route
  and keep saves going straight to the backend, drop `proxy` from both and keep
  `routePrefix`.
* `options.mode` on `ConsentRoot`, or a `hosted({ backendURL })` with its own
  URL, keeps talking to that URL.
* The proxy forwards the visitor's user agent, language, origin and location
  headers, so the backend's firewall sees a normal visitor. Set
  `trustForwardedHeaders: true` on the route only when your host overwrites
  `x-forwarded-for`, as Vercel and Cloudflare do. Otherwise a visitor could
  send a false IP address to your backend.

TanStack Start has the same option, `createConsentStateHandler({ proxy })`.
A full [static export](/docs/frameworks/next/static-export) has no API routes.
Use a hosting-level proxy or the public backend URL there.

## Configure manifest cache refresh

This section applies to runtime fetching. A build-time snapshot stays fixed
until the next build and does not use cache revalidation.

In the App Router, `resolveConsent` and `createConsentRoute` pass
`next: { revalidate: 300 }` to upstream manifest fetches. Next.js's Data Cache
can reuse that policy across requests and server instances when your host
provides a shared cache. A new function instance can therefore read cached
policy even with an empty SDK cache. A Data Cache miss still waits for the
backend.

The SDK also keeps an in-process manifest cache, which supports ETag
revalidation and respects upstream cache headers, including responses marked
private. Pages Router helpers use this cache; ordinary Pages Router fetches
do not use the App Router Data Cache.

For route handlers, `manifestRevalidateSeconds` changes the Data Cache's
300-second revalidation interval. Set it to `false` or `0` to skip that layer.
The SDK cache still follows the upstream cache headers. Visitor-specific
backend `/init` calls use `cache: 'no-store'`, and resolved consent is never
shared between visitors.

Once the upstream `s-maxage` has passed, the in-process cache keeps serving the
cached manifest for as long as the upstream `stale-while-revalidate` allows
(an explicit `s-maxage` is required for that window to apply),
and refreshes it in the background. Requests do not wait for that refresh, and
a refresh that fails or times out leaves the cached manifest in place, so a
slow or unavailable backend does not delay rendering on a server that has
already loaded the manifest. Inth sends `s-maxage=300` and a 24 hour
`stale-while-revalidate` by default; lower the second value on a self-hosted
backend if a policy change must reach servers sooner after an outage. A server
with both caches empty still waits for the first upstream response.

The background refresh is detached from the request. On runtimes that stop
work once a response is sent, register it with the platform so it can finish.
Pass `onBackgroundRevalidate` to the route; it is called inside the handler
with the refresh promise, which never rejects:

```ts title="app/api/c15t/[...c15t]/route.ts"
import { createConsentRoute } from 'c15t/next/api';
import { after } from 'next/server';

export const { GET } = createConsentRoute({
	onBackgroundRevalidate: (refresh) => after(() => refresh),
});
```

`after` is stable from
Next 15.1; on Next 15.0 import `unstable_after` instead. Other hosts pass the
promise to their equivalent, such as `waitUntil` on Vercel or Cloudflare.
Without it the response still returns at once; only the refresh may be cut
short, in which case the next request starts another.

The same hook receives the route's session report. A host that resolves
init from the manifest never calls `/init`, so after each resolution the route
posts a small report to the backend's `POST /sessions`, server-to-server, and
the backend counts the visitor from that instead. The browser makes no request.
`resolveConsent` reports its renders the same way through its `waitUntil`
option. Pass `reportSessions: false` to either to send none.

Build-time snapshots need no background manifest refresh. On serverless
hosts, `onBackgroundRevalidate` can still keep the optional session report
alive after the response. It is not required for resolving consent from the
snapshot.

## Avoid repeating startup work

Keep one boundary in the App Router root layout or Pages Router `_app.tsx`.
Remounting it during navigation creates another runtime and repeats startup
work. Keep the consent UI in that shared root too.

Choose when to resolve consent according to your page. See
[rendering and deployment](/docs/frameworks/next/rendering) for server
rendering, streaming and browser initialization.

Measure cold entry, warm requests and client navigation separately.
Check server requests as well as the browser Network panel. Confirm that
consent saves reach the backend and policy updates appear after a rebuild, or
the configured refresh window with runtime caching. If you set `proxy: true`,
also confirm browser consent requests use your origin.

## Start returning visitors' scripts sooner

`ConsentRoot` loads the script loader as a separate chunk, and only on pages
whose `scripts` array is not empty. Pages without scripts never download it.
By default the chunk loads once the page has hydrated.

When the visitor's consent already lets one of those scripts run, `ConsentRoot`
starts the download during its first render in the browser instead, so the
chunk loads while React hydrates the rest of the page. It decides as the
provider would once mounted: with the state from `resolveConsent()`, once a
streamed state arrives, the current policy, Global Privacy Control, the
visitor's vendor switches and any newer denial stored in the browser. A grant
any of those restricts does not count. The visitors this covers:

* a returning visitor whose stored choice allows one of the scripts;
* any visitor, when a script has `alwaysLoad`, since it runs whatever they
  chose;
* every visitor, when `options.enabled` is `false`, since a disabled provider
  grants every category;
* a visitor under a policy that allows the scripts without a choice, such as
  an opt-out region.

With `options.consentSource` set, and the provider not disabled, the external
source decides consent after the page mounts, so only `alwaysLoad` scripts
start the download early.

The larger the tree inside `ConsentRoot`, the more of that request hydration
covers. There is nothing to configure. The code that decides ships with
`ConsentRoot` only: a `ConsentProvider` from `@c15t/react` rendered without
`ConsentRoot` loads the chunk when it mounts.

c15t does not add a `<link rel="modulepreload">` for the chunk, as it does in
SvelteKit. Next.js assigns the chunk's file name in the browser build, and the
server rendering `ConsentRoot` cannot read it.
