Skip to main content

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.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. To let a build continue the same way, set onBuildError: 'runtime', or run it with C15T_ON_BUILD_ERROR=runtime:

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.

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.

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:

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.

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 pathWork during initialization
Build-time manifest, recommendedResolve consent locally from the deployment's snapshot, including on a fresh server instance.
Regular backend /initWait for the backend to resolve consent and return the result.
Cold manifest cacheFetch public policy from the backend, cache it and resolve consent locally.
Warm manifest cacheReuse 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.

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:

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:

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:

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

export default createPagesConsentRoute({ proxy: true });

What the route serves:

RequestHandled by
GET /api/c15t/initThe route, from the bundled manifest, for this request's location, language and privacy signal
GET /api/c15t/manifestThe route, serving the bundled manifest
POST /api/c15t/subjects and other consent pathsForwarded to your backend because proxy: true
Any other path404, 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 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:

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 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.