Next.js Advanced
Data fetching reference
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.
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. |
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. |
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 | No backend URL, and the mode is not offline() or a hosted() with its own backendURL. |
@c15t/nextjs: defineConsentConfig | 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 is '/'. A catch-all route at the site root would catch every page. |
@c15t/nextjs: defineConsentConfig | mode is not one of the data factories. |
@c15t/nextjs: defineConsentConfig | journey has another value. |
@c15t/nextjs: | 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:
For Pages Router, use this file instead:
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.
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.
Switch to regular backend init
Set mode: hosted() in c15t.config.ts. The
client-side guide
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.
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.
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. |
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
ManifestUnavailableErrorwhosereasonis'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-revalidatewindow, 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
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
/initcall carries the resolvedx-c15t-country,x-c15t-region,accept-languageandsec-gpc, theuser-agent, and the experiment arm while the visitor has no stored choice. Overhttpsor to a loopback host it also carries the consent cookie (cookieName, never the rest of the cookie jar), anyforwardHeaders, and withtrustForwardedHeadersthe visitor IP asx-forwarded-for. It is sent withcache: '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.
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:
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. |
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
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 and Pages Router manifest setups use this flow:
next buildbundles the backend's/manifestresponse as a snapshot. Withmanifest({ source: 'runtime' })instead, the server fetches the absolute backend's/manifestendpoint and caches the public policy data.resolveConsent()resolves policy from that snapshot or cache with the current request's inputs and restores valid consent cookies.ConsentRootreceives that result asstateand reads the rest fromc15t.config.ts. Hydration preserves the resolved state.- If browser initialization is needed, it asks
${routePrefix}/init, whose route resolves the same snapshot on the server, or${backendURL}/initwithoutroutePrefix.manifest({ resolve: 'browser' })resolves in the browser instead. - 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 for cache
settings, and rendering and deployment 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.loadStaticManifestfetches and returns the manifest object.createStaticConsentResolver({ manifest, geo, geoURL })returns a synchronousinitialresult and aresolvedpromise. Withoutgeo, or when ageoURLreturns 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 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 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:
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 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.
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.