Astro Reference
Server API
What the middleware does
The c15t() integration registers a middleware with order: 'pre', so it
runs before your own middleware on every route: pages, endpoints and server
islands. For each request it:
- Reads the visitor's consent cookie.
- Reads location, language and Global Privacy Control from the request headers. See Geography headers.
- Resolves the policy through the integration's
mode. - Stores the result in
Astro.locals.c15t.
The components render from Astro.locals.c15t, and ConsentScript inlines
it for the browser. A server-rendered page therefore needs no /init request
from the browser.
A render waits up to 500 ms for the policy. When the backend is slower, the
page renders without a server decision: no banner in the HTML, optional
categories denied, gated scripts blocked. The browser then requests the
policy itself. Change the budget with middleware: { timeoutMs }. See
set how long a render waits.
On a prerendered page, the middleware reads no request headers or cookie,
because the build has no visitor. hosted() and manifest() leave the
policy for the browser. offline() resolves it at build time, because it
needs no request.
In hosted() mode the render's /init request carries the resolved
location, language and GPC, the user-agent, the mode's configured consent
headers and, while the visitor has no stored choice, the experiment arm. Over
https or to a loopback host it also carries the consent cookie, never the
rest of the cookie jar; over plain HTTP to any other host it carries no
cookie. The render never requests the integration's own consent route: a
backend URL that resolves to it on the request's origin renders without a
server decision.
Type Astro.locals.c15t
The integration adds the type to .astro/types.d.ts, so no src/env.d.ts
line is needed.
What Astro.locals.c15t holds
| Field | Holds |
|---|---|
snapshot | The consent snapshot for this request, the same shape the browser's getConsent() returns |
shouldShowBanner | Whether this request should see the banner |
hasPolicy | Whether a policy rule resolved for this request |
hasConsentUi | Whether that rule owes a banner or a way back to preferences |
prerendered | Whether this render is shared by every visitor, as on a prerendered page |
inputs | The request's country, region, language and gpc values |
decision | The policy decision, when the mode produced one |
config | The payload ConsentScript inlines for the browser |
options | The integration options |
nonce | The Content Security Policy nonce for this request. c15t leaves it unset. Set it from your own middleware, and the components put it on their inline scripts and styles |
On a prerendered page, snapshot is what a first-time visitor would get, and
prerendered is true. On a route listed in middleware.skip, and on the
integration's own consent route, Astro.locals.c15t is unset.
Use consent in server code
Read Astro.locals.c15t in a page, layout or endpoint to decide what the
server renders. A layout can pick the IAB surfaces only for visitors who get
an IAB policy:
Use snapshot.effectivePermissions for what may run on this request, such as
an embed the server can render. Remember that a choice the visitor makes on
the page only reaches the server on the next request. Content that must
appear the moment the visitor allows it belongs in a browser script. See
Embeds.
Do not build consent records from these values. The backend records consent when the browser saves it.
Cache server-rendered pages safely
A page rendered with hosted() or manifest() contains one visitor's
decision: the banner or its absence, and the boot payload with their stored
consent and location. A shared cache that stores that HTML serves it to the
next visitor.
- Do not put request-rendered pages that include c15t components in a shared CDN cache.
- For a page that must be cached, prerender it, or render the banner with
ConsentBannerDeferred. Both keep visitor state out of the cached HTML. - The injected
/api/c15t/initroute answers withCache-Control: private, no-store. Keep it out of shared caches too. - Without a bundled manifest, the injected
/api/c15t/manifestroute passes the backend'sCache-ControlandETagthrough and answersIf-None-Matchwith304, so a CDN can cache it. With one, it returns the build's snapshot with no cache headers.
The injected route
In manifest() mode the integration injects one on-demand route,
${routePrefix}/[...path], which is /api/c15t/[...path] by default:
| Request | Returns |
|---|---|
GET /api/c15t/init | The policy for this request, resolved from the bundled or cached manifest. The browser calls it on prerendered pages, after a failed server resolution, and when a page's consent changes after it loads |
GET /api/c15t/manifest | Your project's public policy file: the build's snapshot when the build bundled one, otherwise proxied from the backend with its cache headers |
The route is the consent route handler from @c15t/core/server, shared
with the Next.js, TanStack Start, SvelteKit and Nuxt adapters. A vendor list that cannot be loaded
fails the init request rather than answering gvl: null, which the browser
would read as "IAB is off". The manifest path passes upstream only a
language query parameter that looks like a language tag.
With a bundled manifest, the route uses the build's snapshot and never fetches
the manifest. Without one, the server keeps the manifest in memory and
refreshes it in the background.
On Cloudflare, c15t hands that refresh to the adapter's waitUntil, so the
request context stays alive until it finishes. The browser saves consent to
the backend URL directly, not to this route.
Change the path with routePrefix: '/consent'. With
manifest({ resolve: 'browser' }) and no adapter, the integration prerenders
the route and writes only ${routePrefix}/manifest, which the browser
fetches. hosted() and offline() inject no route.
Serve the routes yourself
With routePrefix: false, the integration injects nothing and astro build
no longer needs an adapter for it. The browser then asks the backend's
/init whenever a page inits again, and never calls a route of your own at
that path. To keep /init on your origin, keep the injected route and move
it with routePrefix instead.
The handlers the injected route uses are exported as
createConsentRouteHandlers({ options }) from c15t/astro/server. Mount
them yourself for other clients, or for browser resolution: with
mode: manifest({ resolve: 'browser', manifestURL: '/api/c15t/manifest' }),
the browser fetches the manifest from your route. GET(request, { locals })
dispatches by the last path segment.
Helpers in c15t/astro/server
The components and the middleware are built from these helpers. Use them for a custom middleware or your own components:
| Export | Does |
|---|---|
resolveConsentContext({ headers, url, options, prerendered?, timeoutMs?, fetch?, onBackgroundRevalidate? }) | Resolves the value the middleware stores on Astro.locals.c15t |
buildConfigJSON(config) | The boot payload as JSON, for a <script type="application/json" data-c15t-config> data block |
buildConfigScript(config) | The boot payload as a script that sets window.__c15tAstroConfig. The browser still reads it. It changes per visitor, so a policy can allow it only by nonce or 'unsafe-inline' |
buildThemeCSS(theme) | The CSS for <style id="c15t-theme">, or an empty string |
buildColorSchemeScript(colorScheme) | The first-paint color-scheme script, or an empty string |
buildBannerRevealScript(storageConfig, testId) | The inline script that shows a prerendered banner to visitors with nothing stored |
markConfigEmitted(Astro.locals) | Claims the one boot payload per request. Returns true for the first caller |
resolvePromptModel(input) and resolveIABPromptModel(input) | The copy, actions and class names of the banner and the IAB banner |
snapshotFromConfig(config) | Derives a consent snapshot from a resolved configuration |
resolveConsentContext takes the integration options already resolved. Get
them from Astro.locals.c15t.options, or with resolveOptions() from
c15t/astro. Pass prerendered: true only for output that every visitor
shares. It drops the request's cookie and location.
To run c15t's middleware inside your own, set middleware: false and
register onRequest from c15t/astro/middleware with Astro's sequence() in
src/middleware.ts. Put it before anything that renders consent components.
Check the server path
- View the page source of a first visit on a server-rendered page. It
contains
data-testid="consent-banner-root"and a<script type="application/json" data-c15t-config>data block. - Choose, then reload. The source has no banner markup, and DevTools Network
shows no browser request to
/initon that page. - Request
/api/c15t/initand check theCache-Control: private, no-storeresponse header.