Skip to main content

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:

  1. Reads the visitor's consent cookie.
  2. Reads location, language and Global Privacy Control from the request headers. See Geography headers.
  3. Resolves the policy through the integration's mode.
  4. 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

FieldHolds
snapshotThe consent snapshot for this request, the same shape the browser's getConsent() returns
shouldShowBannerWhether this request should see the banner
hasPolicyWhether a policy rule resolved for this request
hasConsentUiWhether that rule owes a banner or a way back to preferences
prerenderedWhether this render is shared by every visitor, as on a prerendered page
inputsThe request's country, region, language and gpc values
decisionThe policy decision, when the mode produced one
configThe payload ConsentScript inlines for the browser
optionsThe integration options
nonceThe 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.

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:

src/layouts/page.astro (partial)
---
import BaseLayout from './base.astro';
import IABLayout from './iab.astro';

const Layout =
  Astro.locals.c15t?.snapshot.model === 'iab' ? IABLayout : BaseLayout;
---

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/init route answers with Cache-Control: private, no-store. Keep it out of shared caches too.
  • Without a bundled manifest, the injected /api/c15t/manifest route passes the backend's Cache-Control and ETag through and answers If-None-Match with 304, 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:

RequestReturns
GET /api/c15t/initThe 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/manifestYour 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:

ExportDoes
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

  1. 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.
  2. Choose, then reload. The source has no banner markup, and DevTools Network shows no browser request to /init on that page.
  3. Request /api/c15t/init and check the Cache-Control: private, no-store response header.