---
title: Data fetching reference
description: Reference for the Next.js c15t.config.ts options, modes,
  resolveConsent, withConsentProps, the consent route, manifest resolution,
  request geography and offline configuration.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Config reference

`defineConsentConfig` from `c15t/next` declares the app's consent setup in
`c15t.config.ts` at the project root. `withConsentManifest` in
`next.config.ts` finds the file and hands it to `ConsentRoot`,
`resolveConsent`, `withConsentProps`, `createConsentRoute` and
`createPagesConsentRoute`, so the app never imports it. For help choosing a
setup, start with [rendering and deployment](/docs/frameworks/next/rendering).
Requires Next.js 15 or 16 (`next ^15.0.0 || ^16.0.0`).

|Property|Default|Behavior|
|--|--|--|
|`backendURL`|`NEXT_PUBLIC_C15T_BACKEND_URL`, then `NEXT_PUBLIC_INTH_PROJECT_URL`|Backend base URL. Consent submissions go to `/subjects` under it, and the server reads `/manifest` or `/init` from it.|
|`mode`|`manifest()`|`manifest()`, `hosted()` or `offline()` from `c15t/next`. See [consent modes](/docs/concepts/modes).|
|`routePrefix`|none|Where the catch-all consent route is mounted, such as `/api/c15t`. A browser that resolves consent itself asks `${routePrefix}/init` instead of the backend's `/init`. Leave it unset when every page resolves consent on the server.|
|`proxy`|`false`|The route at `routePrefix` forwards saves (`createConsentRoute({ proxy: true })`), so the browser saves there instead of `${backendURL}/subjects`. The server helpers keep `backendURL`. Needs `routePrefix`. See [optimization](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin).|
|`journey`|`'page'`|The consent journey scope, read by both `resolveConsent` and `ConsentRoot` so they agree.|
|`scripts`, `vendors`, `clearOnRevocation`, `networkBlocker`, `persistence`, `scriptLoader`, `options`|none|Browser options. `ConsentRoot` props of the same name win over them.|

The file is bundled into the browser as well as the server, so it can hold
functions such as `scripts` but must hold no secrets. Read server-only values,
such as a token for `forwardHeaders`, in server code.

Values can be absolute HTTP or HTTPS URLs or paths beginning with `/`. Server
helpers resolve relative paths against the incoming request's host. 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, use an
absolute `backendURL`. A relative one resolves against whatever `Host` the
request sent, so the request would choose the origin the server fetches.

`defineConsentConfig` returns a frozen object. On the server, which includes
`withConsentManifest` reading the file at build time, it validates first and
throws a `TypeError` with one of these messages. Browser bundles skip the
checks, so they add no bytes there:

|Message|Cause|
|--|--|
|`@c15t/nextjs: defineConsentConfig expects an object.`|The argument is not an object.|
|`` @c15t/nextjs: defineConsentConfig needs `backendURL`, or NEXT_PUBLIC_C15T_BACKEND_URL (or NEXT_PUBLIC_INTH_PROJECT_URL) set at build time. ``|No backend URL, and the mode is not `offline()` or a `hosted()` with its own `backendURL`.|
|`` @c15t/nextjs: defineConsentConfig `backendURL` must be an absolute http(s) URL or a `/`-relative path, received "...". ``|A URL is protocol-relative, such as `//your-project.inth.app`, or bare, such as `api/c15t`. The same check covers `routePrefix`, `mode.manifestURL`, `mode.geoURL` and `mode.backendURL`.|
|`` @c15t/nextjs: `routePrefix` can't be '/': a consent route at the site root would catch every page. Use a path such as '/api/c15t'. ``|`routePrefix` is `'/'`. A catch-all route at the site root would catch every page.|
|`` @c15t/nextjs: defineConsentConfig `mode` must be manifest(), hosted() or offline() from c15t/next. Pass a custom transport through ConsentRoot `options.mode`. ``|`mode` is not one of the data factories.|
|`` @c15t/nextjs: defineConsentConfig `journey` must be 'page', 'tab' or false. ``|`journey` has another value.|
|`` @c15t/nextjs: `proxy` sends saves through the consent route, so it needs `routePrefix`. ``|`proxy: true` without `routePrefix`.|

Protocol-relative and bare URLs are rejected because the server helpers would
resolve them against the request host and reach an unintended origin.

### Do I need the consent route?

Not when every page resolves consent on the server. The root layout or
`withConsentProps` supplies the initial policy directly to the browser.

Add the route when some pages render without server state: static, ISR or
`'use cache'` pages in the App Router, or Pages Router pages without
`withConsentProps`. Set `routePrefix: '/api/c15t'` in `c15t.config.ts` and
mount the route:

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

export const { GET } = createConsentRoute();
```

For Pages Router, use this file instead:

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

export default createPagesConsentRoute();
```

The route answers `GET ${routePrefix}/init` by resolving the bundled manifest
with the browser request's location headers, so those pages get the same
policy a server render would. It also answers `GET ${routePrefix}/manifest`
for `manifest({ resolve: 'browser' })`.

Without `routePrefix`, browser initialization calls the backend's `/init`.
See [geography and privacy signals](#geography-and-privacy-signals).

### Mode combinations

|Config|Server render|Browser initialization|
|--|--|--|
|`manifest()`, the default, with `routePrefix`|Resolves the snapshot with request inputs|Calls `${routePrefix}/init`, whose route resolves the snapshot with request inputs|
|`manifest()` without `routePrefix`|Resolves the snapshot with request inputs|Calls `${backendURL}/init`|
|`manifest({ resolve: 'browser' })`|Resolves the snapshot with request inputs|Loads the resolver lazily and fetches `${routePrefix}/manifest`, else `${backendURL}/manifest`|
|`manifest({ source: 'runtime' })`|Fetches and caches `${backendURL}/manifest`|As `manifest()`|
|`hosted()`|Calls `${backendURL}/init`|Calls `${backendURL}/init`|
|`offline()`|No policy request|Resolves bundled local rules|

Hosted and manifest modes submit choices to `${backendURL}/subjects`.
Offline mode uses browser persistence only. A prepared server result
satisfies the first browser initialization; the browser column describes
what happens when initialization is needed, including recovery after the
server render failed.

The browser loads only the code of the mode it runs. With `manifest()`
resolved on the server, the snapshot, the resolver and other languages stay
out of the browser bundle; see
[what each mode adds to first-load JavaScript](/docs/concepts/modes#what-each-mode-adds-to-first-load-javascript).

### Switch to regular backend init

Set `mode: hosted()` in `c15t.config.ts`. The
[client-side guide](/docs/frameworks/next/client-side#configure-the-backend-and-the-mode)
has the complete config. For server rendering, keep the layout from your
router guide. `resolveConsent` will call backend `/init` per request.

Remove the consent route if no page needs it, unless `proxy: true` sends
browser saves through it. See
[optimization](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin).

## Server helper options

`resolveConsent` is the one server helper. `c15t/next/server` exports it for
the App Router, where the default request context reads `next/headers` and
calls `await connection()` from `next/server` before reading the clock.
`c15t/next/pages` exports the same function for the Pages Router, taking the
Node `req` instead, and `withConsentProps` wraps it as a `getServerSideProps`.
All of them accept the options in this section; the Pages Router entry
replaces only the `request` adapter.

`resolveConsent()` needs no options: it reads `c15t.config.ts` and the
snapshot `withConsentManifest` downloaded. What it does depends on the mode.
`manifest()` resolves policy from the snapshot, or from
`${backendURL}/manifest` through the in-process cache without one. `hosted()`
calls `${backendURL}/init`. `offline()`, or no backend URL at all, makes no
network request and returns cookie- and header-only state; see
[cookie-only state](#cookie-only-state).

### resolveConsent options

|Option|Default|Behavior|
|--|--|--|
|`config`|`c15t.config.ts`|The config to use instead of the file. Explicit options on this bag win over its fields. In manifest mode, its `routePrefix` names the same-origin route the browser loads a deferred IAB Global Vendor List from.|
|`backendURL`|the mode's, then the config's, then `NEXT_PUBLIC_C15T_BACKEND_URL` or `NEXT_PUBLIC_INTH_PROJECT_URL`|Backend base URL. With `hosted()`, `resolveConsent` calls `${backendURL}/init`.|
|`manifestURL`|the mode's absolute `manifestURL`, then `${backendURL}/manifest`|Absolute `GET /manifest` URL. Setting it resolves from the manifest whatever the mode, through the in-process manifest cache. A `/`-relative `manifestURL` on the mode is the browser's, so the server ignores it.|
|`snapshot`|the snapshot `withConsentManifest` downloaded|A manifest to resolve from instead. Setting it resolves from the manifest whatever the mode.|
|`fetch`|`globalThis.fetch`|Fetch implementation for the backend `/init` or manifest request. Manifest requests already pass `next: { revalidate: 300 }` for the App Router Data Cache. The `/init` call can carry the consent cookie and returns per-visitor state, so it uses `cache: 'no-store'`. A custom fetch must preserve these options. It also keeps the IAB Global Vendor List inline, since the browser cannot replay it.|
|`forwardHeaders`|`[]`|Extra request header names copied onto the outgoing call, such as a token a private backend needs. They travel only over `https` or to a loopback host. `cookie` and `forwarded`/`x-forwarded-*` cannot be named here. Headers absent from the request are skipped.|
|`trustForwardedHeaders`|`false`|Resolve a `/`-relative `backendURL` or `manifestURL` against the request's `forwarded`, `x-forwarded-host` and `x-forwarded-proto` headers instead of `host`, and forward the visitor IP to backend `/init` as `x-forwarded-for`. Set it only behind a proxy that sets those headers and drops the ones a client sends.|
|`onError`|none|Receives the failure from the backend or manifest request, including a `ManifestUnavailableError` when `timeoutMs` runs out or the manifest is backing off after a failure. When omitted, failures are logged with `console.warn` only when `NODE_ENV` is not `production`.|
|`timeoutMs`|`500`|Longest the render waits for policy, in milliseconds, counted from the start of the resolution. When it runs out, the helper returns the baseline state described below. `false` or `Infinity` waits for the manifest cache's 5 second request timeout, or for the `fetch` implementation on backend `/init`. Any other value that is not a finite, non-negative number uses the default.|
|`reportSessions`|`true`|In manifest mode, report the resolved init to the backend's `POST /sessions` after the render, server-to-server, so the backend still counts the visitor. Forwards the visitor's user agent and client IP on `x-c15t-client-ip`, never cookies. `false` sends none.|
|`journey`|the config's `journey`|The consent journey scope this render reports. `ConsentRoot` must use the same value.|
|`experiment`|none|The banner experiment with the arm this request runs. See [banner experiments](/docs/guides/banner-experiments).|
|`waitUntil`|none|Receives work that outlives the render so it survives the response: the session report, a manifest refresh, and a manifest request that `timeoutMs` stopped waiting for. In the App Router pass `(task) => after(() => task)` with `after` from `next/server`. The promise never rejects.|
|`now`|`Date.now()`|Clock used to validate stored records and stamped into the result.|
|`cookieName`|`c15t`|Cookie holding persisted consent. Must match the client `storageConfig.storageKey`.|
|`country`|header detection|Overrides the country read from request headers.|
|`language`|header detection|Overrides the language read from `accept-language`.|
|`request`|`next/headers`|Request context adapter with `cookies()` and `headers()`. The default only works in the App Router.|

`resolveConsent` throws when the request adapter's `headers()` or `cookies()`
rejects (the default adapter rejects outside a request scope), and when your
`onError` callback throws. Thrown URL resolution, network, timeout and policy
resolution errors are handled: they return the same baseline state as the
cookie-only call, with stored records, geography, language and GPC from the
request but no resolved policy. The page still renders and the browser initializes
consent on mount. Non-2xx responses from backend `/init` are failures too, so
a `500` renders the baseline rather than throwing. A successful response whose
body reports `policyResolution.status: 'failed'`, for example an unsupported
policy contract, is different: that failed resolution is kept as the prepared
state, the browser does not re-initialize on mount, and the consent UI stays
hidden until the cause is fixed.

### Slow or unavailable backends

`resolveConsent` waits at most `timeoutMs` (500 ms by default) for policy. A
warm manifest cache answers in about a millisecond, and a cold read over a new
connection to a hosted backend usually takes a few hundred. When the budget
runs out, the render uses the baseline state: no consent UI in the server HTML,
optional categories denied, and consent-gated scripts and iframes blocked.
`ConsentRoot` then initializes in the browser and shows the banner once the
backend answers, retrying failed attempts with backoff.

The manifest request does not stop when the render gives up. It keeps running,
up to the cache's 5 second request timeout, and stores the manifest for later
renders. Pass `waitUntil` so serverless platforms keep it alive after the
response.

Server manifest reads, in `resolveConsent` and in the route handlers, share
these rules:

* Concurrent requests for the same manifest URL share one upstream request.
* A render that joins a request already in flight waits only for what is left
  of that request's `timeoutMs`.
* After a failed request, with no usable copy cached, that URL is not asked
  again for 1 second. Each further failure doubles the wait, up to 5 seconds.
  Requests in between fail at once with a `ManifestUnavailableError` whose
  `reason` is `'backoff'`, so a struggling backend sees at most one request
  per URL per server instance in each interval.
* A stale copy is served only inside the backend's `stale-while-revalidate`
  window, while one background request refreshes it. Past that window the read
  waits for the backend like a miss and never falls back to the expired copy.

In the App Router, both `resolveConsent` and `createConsentRoute` pass
`next: { revalidate: 300 }` to upstream manifest fetches. Next.js's Data
Cache can satisfy an in-process cache miss, including on a new server instance
when your host provides a shared cache. A `manifestURL` pointing directly at
the backend uses these same cache layers. The consent route also provides a
cacheable response for browsers and your CDN.

Pages Router helpers keep the in-process cache, but ordinary Pages Router
fetches do not use the App Router Data Cache. A build-time snapshot avoids
manifest cache misses in both routers and stays fixed until rebuilding.

Absolute `http(s)` URLs are used as given. `resolveConsent` resolves a
`/`-relative `backendURL` or `manifestURL` against the request's `host`
header. A domain name resolves over `https`. `localhost`, an IP address or a
single-label host such as `app:3000` resolves over `http`. The
`x-forwarded-host`, `x-forwarded-proto`, `forwarded` and `referer` headers
are ignored, because any client can send them and the `/init` call carries the
visitor's consent cookie to the resolved host. When no `host` header is
available, `resolveConsent` reports the error and returns the baseline state.

A render never fetches your own consent route: a URL on the request's origin
under the config's `routePrefix`. `resolveConsent` reports the URL and returns
the baseline state instead. Keep the config's `backendURL` absolute, and use
[`proxy: true`](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin)
to keep the browser on your origin.

Any other same-origin `backendURL` is a backend reached through your app, such
as `/api/c15t` with a rewrite or a mounted `@c15t/backend`. `resolveConsent`
calls its `/init` like any backend. That request goes back through your own
server, which costs a second function invocation on serverless hosts and fails
behind deployment protection; the render then returns the baseline state and
the browser resolves the policy. Pass the backend's own URL to skip the hop.
If the prefix reaches no backend and a page answers instead, that page's
`resolveConsent` sees the request came from a render and does not fetch again.

Behind a proxy that sets `x-forwarded-host` and `x-forwarded-proto` and drops
the values a client sends, pass `trustForwardedHeaders: true` to resolve
against them instead. Use `https` for any production `backendURL`. Over plain
HTTP to anything but a loopback host, the `/init` call carries no cookie, no
`forwardHeaders` and no client IP.

Forwarded headers differ by path, and only what the backend needs travels:

* The backend `/init` call carries the resolved `x-c15t-country`,
  `x-c15t-region`, `accept-language` and `sec-gpc`, the `user-agent`, and the
  experiment arm while the visitor has no stored choice. Over `https` or to a
  loopback host it also carries the consent cookie (`cookieName`, never the
  rest of the cookie jar), any `forwardHeaders`, and with
  `trustForwardedHeaders` the visitor IP as `x-forwarded-for`. It is sent with
  `cache: 'no-store'`.
* The manifest request carries only the headers named in `forwardHeaders`,
  because the manifest is public policy data.

Cookies are still read locally on both paths to restore records.

An inline `snapshot`, including the one `withConsentManifest` downloaded, is
never refreshed. The manifest transport returns the
object as given instead of fetching it, so the snapshot is the source of truth
for that request rather than a cache seed. A stale snapshot resolves stale
policy until the application ships a new one. One request can still happen:
when the inline manifest has `iab.enabled: true` with an `iab.gvl` reference
and the matched rule uses the `iab` model, the transport fetches the Global
Vendor List with the `fetch` option, and a blocked network there also falls
back to the baseline. `backendURL` is still required because choices post to
`${backendURL}/subjects`.

`onError` replaces the default logging entirely. Without it, production
deployments render the baseline silently; pass `onError` to report failures to
your monitoring. `cookieName` must match the client `storageKey` for stored
choices to be restored at all: with a mismatch the server supplies empty
records, the provider treats them as prepared and skips browser hydration, so
the visitor's existing choice stays ignored for the whole mount, not only at
first paint.

### Cookie-only state

With `mode: offline()`, or when no backend URL is configured anywhere,
`resolveConsent` returns state that needs the visitor's stored records but no
resolved policy. It makes no network request. It reads the request and returns a JSON-serializable
`ConsentState` with `initialRecords`, `initialPrivacySignals.gpc`, `now` and,
when any value was detected, `initialOverrides` with `country`, `region` and
`language`. It does not set cookies and does not cache across requests. Only
these options apply:

|Option|Default|Behavior|
|--|--|--|
|`now`|`Date.now()`|Clock used to validate stored records and stamped into the result.|
|`cookieName`|`c15t`|Cookie holding persisted consent. Must match the client `storageConfig.storageKey`.|
|`country`|header detection|Overrides the country read from request headers.|
|`language`|header detection|Overrides the language read from `accept-language`.|
|`request`|`next/headers`|Request context adapter with `cookies()` and `headers()`. The default only works in the App Router.|

The cookie header is read from `headers().get('cookie')` first and from
`request.cookies()` only when that header is absent. Country and region come
from the headers listed in [geography and privacy signals](#geography-and-privacy-signals).

### Types

|Type|Exported from|What it names|
|--|--|--|
|`ConsentState`|`c15t/next`, `c15t/next/server`, `c15t/next/pages`|The value `resolveConsent` returns and `ConsentRoot` takes as `state`|
|`ResolveConsentOptions`|`c15t/next/server`|The App Router options bag|
|`ConsentRequestOptions`|`c15t/next/server`|The request-reading subset: `now`, `cookieName`, `country`, `language`, `request`|
|`PagesResolveConsentOptions`|`c15t/next/pages`|`ResolveConsentOptions` with `req` in place of `request`|
|`ConsentRootProps`|`c15t/next`|Props of `ConsentRoot`; `ConsentRootProps['state']` also accepts the pending promise|
|`ConsentConfig`|`c15t/next`, `c15t/next/server`, `c15t/next/pages`, `c15t/next/api`|The frozen `defineConsentConfig` result|
|`ConsentMode`|`c15t/next`|What `manifest()`, `hosted()` and `offline()` return|
|`NextConsentRouteOptions`|`c15t/next/api`|Options of `createConsentRoute` and `createPagesConsentRoute`|
|`ConsentPageProps`|`c15t/next/pages`|`pageProps` of a page that exports `withConsentProps`, for `AppProps<ConsentPageProps>`|
|`WithConsentPropsOptions`|`c15t/next/pages`|`PagesResolveConsentOptions` without `req`|

### Pages Router differences

`withConsentProps(getServerSideProps?, options?)` from `c15t/next/pages` is a
`getServerSideProps` that resolves consent and adds it to the page's props as
`consent`. It wraps the page's own `getServerSideProps` when given; consent
resolves while it runs, and its `redirect` and `notFound` results pass
through. `options` takes every option in the App Router table except
`request`. The state is round-tripped through JSON, because Next.js rejects
`undefined` prop values such as an absent GPC signal.

`c15t/next/pages` also exports `resolveConsent` with the Node request in place
of the `request` adapter. `resolveConsent({ req, ...options })` accepts every
option in the App Router table except `request`; `req` is the request from
`getServerSideProps` or an API route. Its result can hold `undefined` fields,
so round-trip it through `JSON.parse(JSON.stringify(result))` before returning
it as a prop, or use `withConsentProps`, which does that for you.

`createPagesRequestContext(req)` builds the `request` adapter itself. Headers
are converted to Web `Headers`, and cookies are read from the `cookie` header.
Use it when calling the `c15t/next/server` helper from a custom server or
test harness where `next/headers` is unavailable:

```ts title="server/consent.ts"
import type { IncomingMessage } from 'node:http';

import { createPagesRequestContext } from 'c15t/next/pages';
import { resolveConsent } from 'c15t/next/server';

export function resolveRequestConsent(req: IncomingMessage) {
	return resolveConsent({ request: createPagesRequestContext(req) });
}
```

## Route handler options

### createConsentRoute options

`createConsentRoute(options?)` from `c15t/next/api` returns `GET` for one
catch-all route, such as `app/api/c15t/[...c15t]/route.ts`. `GET` answers
`/init` and `/manifest` under the route; every other path answers 404. With
`proxy`, it also returns `POST`, `PATCH`, `PUT`, `DELETE` and `OPTIONS`, and
forwards the other consent paths to the backend. Everything defaults to
`c15t.config.ts` and the snapshot `withConsentManifest` downloaded.

|Option|Default|Behavior|
|--|--|--|
|`config`|`c15t.config.ts`|The config to use instead of the file.|
|`backendURL`|the config's, then `NEXT_PUBLIC_C15T_BACKEND_URL` or `NEXT_PUBLIC_INTH_PROJECT_URL`|Backend base URL. Without a snapshot, the route fetches `${backendURL}/manifest`.|
|`manifestURL`|the mode's absolute `manifestURL`|Full upstream manifest URL. Takes precedence over `backendURL` plus `/manifest`.|
|`snapshot`|the snapshot `withConsentManifest` downloaded|Takes precedence over upstream URLs, serves the manifest and resolves each visitor without fetching policy. Stays fixed until rebuilding.|
|`proxy`|`false`|Forward `subjects`, `subjects/:id`, `health` and `status` to the backend, so browser saves stay on your origin. Pair it with `proxy: true` in `c15t.config.ts`. `createPagesConsentRoute` takes it too.|
|`manifestRevalidateSeconds`|`300`|Next.js Data Cache revalidation for the manifest fetch. `false` disables it.|
|`fetch`|`globalThis.fetch`|Fetch implementation for the manifest and Global Vendor List requests.|
|`trustForwardedHeaders`|`false`|Resolve a `/`-relative `backendURL` or `manifestURL` against the request's `forwarded`, `x-forwarded-host` and `x-forwarded-proto` headers instead of `request.url`. Set it only behind a proxy that sets those headers and drops the ones a client sends.|
|`onBackgroundRevalidate`|none|Receives detached work started by a request: a background manifest refresh, and the init route's session report. Keep it alive with `after` from `next/server` or a platform `waitUntil`. Called inside the handler; the promises never reject. See [Optimization](/docs/frameworks/next/optimization#configure-manifest-cache-refresh).|
|`reportSessions`|`true`|Report each init the route resolves to the backend's `POST /sessions`, server-to-server and detached from the response, so the backend still counts the visitor. Needs an absolute `backendURL`; nothing is inferred from a manifest URL. `false` sends none.|
|`fetchGvl`|built-in cached fetcher|Loads the Global Vendor List for IAB policies. Called only under the conditions described in this section.|

With no snapshot, `manifestURL` wins over `backendURL`. URLs are resolved per
request, so a missing or invalid value fails the request rather than the
build: with no snapshot and neither `backendURL` nor `manifestURL`, the
handler throws. A
`/`-relative value is resolved against the origin of `request.url`, which
Next.js builds itself. Forwarding headers are read only with
`trustForwardedHeaders: true`. A relative value still points the handler at
your own app, so keep upstream URLs absolute; otherwise the consent route
fetches itself.

A `/`-relative `manifestURL` on the config's mode names the route itself, so
the handler ignores it. Keep the config's `backendURL` absolute. To keep the
browser on your origin, set [`proxy: true`](/docs/frameworks/next/optimization#keep-browser-consent-requests-on-your-origin)
rather than a relative `backendURL`.

The handlers are built on the consent route handler in `@c15t/core/server`,
which the TanStack Start, SvelteKit, Astro and Nuxt adapters share, so the
rules below are the same in every framework.

`GET ${routePrefix}/init` resolves the build snapshot or runtime cached manifest with the request's
geography, language and GPC headers and responds with `cache-control: private, no-store` and
`x-c15t-policy-contract: 1`. The payload echoes the inputs it used as
`resolvedOverrides` and `resolvedPrivacySignals`. The browser sends its
overrides and policy contract as the `country`, `region`, `gpc` and
`contract` query parameters, which win over the matching `x-c15t-*` headers. When the request declares a different policy contract in
either form, the response keeps the
translations and UI data but sets `policyResolution` to `status: 'failed'`
with `reason: 'unsupported-contract'`. Whenever the resolution is not
`matched`, the payload carries no `policySnapshotToken`, `gvl`,
`gvlReference`, `cmpId` or `customVendors`. Requests that declare no contract
are treated as compatible.

With a snapshot, `GET ${routePrefix}/manifest` returns the complete snapshot as
JSON with status `200`. It makes no upstream request, adds no cache or validator
headers and does not answer `If-None-Match` with `304` or slice by `language`.

With runtime fetching, `GET ${routePrefix}/manifest` forwards the upstream `cache-control`,
`etag`, `last-modified` and `content-language` headers, adds `content-type: application/json` and an
`age` computed from the in-process cache, and answers a matching
`If-None-Match` with `304`. It never invents a `cache-control` header the
upstream did not send. The only query parameter passed upstream is a
`language` that looks like a language tag, lower-cased; every other parameter
is dropped, so a visitor's query string neither reaches the backend nor adds
cache entries.

With runtime fetching, when the manifest cannot be read and `backendURL` is
set, `GET ${routePrefix}/init` asks the backend's own `GET /init` instead, forwarding only the geography, language and
GPC headers. This covers backends without `/manifest`. It happens for a `404`
and for the first failure of a failing backend, not for every request while
the manifest cache backs off. Otherwise the handler rejects and Next.js
answers `500`.

`fetchGvl` runs inside the init path only when the manifest has `iab.enabled: true`,
the manifest includes an `iab.gvl` reference, and the resolved policy matched
with `model: 'iab'`. It receives the reference, the `fetch` option, and the
language taken from the first segment of the resolved translations language
(`en` when empty). The default fetcher caches the vendor list in process and
aborts the upstream request after five seconds. A fetched list becomes a
`gvlReference` and a small banner summary in the serialized payload. A `null`
result keeps IAB unavailable. A rejected or timed-out fetch fails the request
rather than answering `gvl: null`, because the browser reads `null` as "IAB is
off" and would show a policy that requires the TCF without it. With
`routePrefix`, the browser reads the list from that same-origin route;
otherwise it uses the manifest's public list URL. The banner shows the same
purpose names and vendor count on the server and during hydration. A
client-only `vendors` allowlist on the IAB config makes that server summary
unusable, so the banner waits for the full list. Filter vendors on the server
when the banner must render immediately.

`createPagesConsentRoute(options?, param?)` from `c15t/next/pages` accepts the
same options except `proxy` and returns the default export of a catch-all
`pages/api` route, `pages/api/c15t/[...c15t].ts`. `param` names the catch-all
parameter and defaults to `c15t`; a file with another name throws
`` @c15t/nextjs: createPagesConsentRoute found no `c15t` catch-all parameter. Name the file [...c15t].ts or pass its parameter name. ``
Because a `pages/api` default export receives every method, requests other
than `GET` and `HEAD` are answered with `405` and an `allow: GET` header before
the wrapped handler runs. The bridge
rebuilds the request URL from the `host` header, over `http` for `localhost`,
IP and single-label hosts and `https` otherwise, and reads forwarding headers
only with `trustForwardedHeaders: true`.

## Manifest request resolution

The [App Router](/docs/frameworks/next/app-router) and
[Pages Router](/docs/frameworks/next/pages-router) manifest setups use this flow:

1. `next build` bundles the backend's `/manifest` response as a snapshot.
   With `manifest({ source: 'runtime' })` instead, the server fetches the
   absolute backend's `/manifest` endpoint and caches the public policy data.
2. `resolveConsent()` resolves policy from that snapshot or cache with the
   current request's inputs and restores valid consent cookies.
3. `ConsentRoot` receives that result as `state` and reads the rest from
   `c15t.config.ts`. Hydration preserves the resolved state.
4. If browser initialization is needed, it asks `${routePrefix}/init`, whose
   route resolves the same snapshot on the server, or `${backendURL}/init`
   without `routePrefix`. `manifest({ resolve: 'browser' })` resolves in the
   browser instead.
5. Browser choices post to `${backendURL}/subjects`.

A warm policy cache avoids backend `/init` during request resolution. The app
may still read its consent route, cold caches fetch upstream data, and choices
still reach the backend. IAB policies can also require a Global Vendor List
fetch. See [manifest caching](/docs/frameworks/next/optimization) for cache
settings, and [rendering and deployment](/docs/frameworks/next/rendering) for
awaiting or streaming prefetch results.

## Static manifest helpers

`c15t/next/static` exports `loadStaticManifest`, `createStaticManifestModule`,
`createStaticConsentResolver` and `resolveUnknownLocationInit` for resolving
consent from a manifest bundled at build time, for example in an
`output: 'export'` site. The two resolvers come from `c15t/static`, which
`c15t/tanstack-start/static` re-exports too.

* `createStaticManifestModule({ manifestURL })` fetches a manifest and returns
  TypeScript source for a module you write to disk and import.
* `loadStaticManifest` fetches and returns the manifest object.
* `createStaticConsentResolver({ manifest, geo, geoURL })` returns a synchronous
  `initial` result and a `resolved` promise. Without `geo`, or when a `geoURL`
  returns no location, it uses the manifest's unknown-location policy: its
  fallback rule, else its default rule. That is the same policy the server
  helpers apply when a request has no location headers. Rules scoped to a
  country or region never apply to an unknown location.
* `resolveUnknownLocationInit(manifest, { language, gpc })` returns that
  unknown-location result on its own.

A manifest with neither a fallback rule nor a default rule cannot resolve an
unknown location. The resolvers then return a failed result with the reason
`insufficient-inputs`, and the client applies its safe fallback. Give your
policy a fallback rule before you bundle it.

A generated manifest contains public policy, not a visitor's choice. The
helpers do not mount a provider, persist choices or send consent to the
backend. You wire them into a custom transport passed as `options.mode`, and
publishing new policy needs a rebuild. The
[static export guide](/docs/frameworks/next/static-export) uses backend `/init`
instead, which needs none of this. Never bake a build machine's location or
cookies into a shared static page.

A static export can also resolve a manifest in the browser without these
helpers. Set `mode: manifest({ resolve: 'browser', manifestURL })` in
`c15t.config.ts`, with an absolute `manifestURL` the browser can fetch from
your site's origin. Browser manifest resolution has no
location input, so every visitor gets your unknown-location rule.

## Geography and privacy signals

Server manifest resolution reads location headers from the hosting platform.
It recognizes country headers such as `cf-ipcountry` and
`x-vercel-ip-country`, and region headers such as `cf-region-code` and
`x-vercel-ip-country-region`. The application overrides `x-c15t-country` and
`x-c15t-region` take precedence. Only trusted infrastructure should supply
location overrides in production.

Request helpers also read language and GPC. Missing location stays unknown;
the resolver does not infer country from the Next.js server's IP. Test unknown
country and region against your configured policy rules.

Browser manifest resolution uses location overrides from prefetch, `inputs`
or `geoURL`. Without them, a location-based policy asks the backend's `/init`.
The [consent route](#do-i-need-the-consent-route) reads geographic headers on
the server and keeps resolver code and translations out of browser
initialization.

## Offline configuration

Not recommended for production environments. Use offline mode for local
development, tests or demos that do not need backend records.

Set `mode: offline()` in `c15t.config.ts`. It needs no backend URL:

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

export default defineConsentConfig({ mode: offline() });
```

Keep your layout. `resolveConsent()` then makes no network request and the
browser resolves the rules after hydration. `offline()` from `c15t/next` is
data, so the offline rules load with `import()` only in apps that use them.

The default local policy pack handles missing geography; offline mode does not
perform IP lookup. Supply `policyRules` to replace that pack when your local
policy needs different behavior. Browser persistence stores choices, but this
setup has no backend record service. See
[transport choices](/docs/concepts/data-fetching) for the tradeoffs.

Offline mode runs only when you choose it. `ConsentRoot` throws when
`manifest()` or `hosted()` finds no backend URL: not in the mode, the config
or `NEXT_PUBLIC_C15T_BACKEND_URL`. That happens only without a
`c15t.config.ts`, because `defineConsentConfig` throws without a backend URL.
See [troubleshooting](/docs/frameworks/next/troubleshooting#why-does-consentroot-throw-that-it-needs-a-backend-url).

### Authenticated hosted vendor lists

When hosted `resolveConsent()` forwards the consent cookie or additional
request headers, or uses a custom `fetch`, it retains the fetched vendor list
in server state.
The browser cannot replay a private server fetch. This fallback preserves consent
loading and vendor filtering without copying credentials into the page. Its
payload size is unchanged from inline GVL loading. For compact pages with private
upstreams, expose the public list through the consent route with
`routePrefix`.
