Next.js Advanced
Performance
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:
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.
To let a build continue the same way, set onBuildError: 'runtime', or run it
with C15T_ON_BUILD_ERROR=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.
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:
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.
To apply policy edits without a rebuild, set
mode: manifest({ source: 'runtime' }) in c15t.config.ts. The server then
uses runtime manifest caching.
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:
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.
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. 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. 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:
Then mount the consent route with proxy: true, so it forwards saves to the
backend. In the App Router:
In the Pages Router:
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: trueinc15t.config.tspoints the browser atroutePrefixfor init and saves.resolveConsent,withConsentPropsand the consent route keep the absolutebackendURLfrom the config orNEXT_PUBLIC_C15T_BACKEND_URL, so the server never fetches its own route andwithConsentManifeststill downloads the manifest from the backend.proxyneedsroutePrefix. Without it,defineConsentConfigthrows@c15t/nextjs: `proxy` sends saves through the consent route, so it needs `routePrefix`.- Set
proxy: truein 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, dropproxyfrom both and keeproutePrefix. options.modeonConsentRoot, or ahosted({ 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: trueon the route only when your host overwritesx-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 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:
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 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.enabledisfalse, 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.