---
title: Rendering and deployment
description: Choose how c15t resolves consent on an Astro site, from static
  output with hosted() or browser-resolved manifest() to server output with
  manifest(), prerendered pages, server islands, ClientRouter and offline
  development.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Choose a rendering path

Where consent resolves decides what the first HTML contains. A page that is
built once and served to everyone cannot contain one visitor's consent, so the
browser finishes the job. A page rendered per request can.

|Your site|Astro setup|c15t mode|Banner in the first HTML|
|--|--|--|--|
|Static pages on any static host|`output: 'static'`, no adapter|`hosted()`|No. The browser renders it after `/init`|
|Static pages, with the policy resolved in the browser|`output: 'static'`, no adapter|`manifest({ resolve: 'browser' })`|No. The browser renders it after reading the manifest|
|Pages rendered on each request|`output: 'server'` and an adapter|`manifest()`, the default|Yes|
|A server build with some prerendered pages|`output: 'server'`, `prerender = true` on those pages|`manifest()`|Only on the request-rendered pages|
|Cached pages that still need a per-visitor banner|An adapter, and `ConsentBannerDeferred` in the layout|`manifest()`|Yes, from a server island|
|Local development and tests with no backend|Either output|`offline()`|Depends on the output|

Every path uses the same layout, client entrypoint and scripts from the
[quickstart](/docs/frameworks/astro/quickstart). Only `astro.config.mjs` and,
for cached pages, the banner component change.
[Choose your setup](/docs/concepts/choose-your-setup) explains the trade-off
across frameworks.

## Static output with `hosted()`

```js title="astro.config.mjs"
import svelte from '@astrojs/svelte';
import { defineConfig } from 'astro/config';
import c15t, { hosted } from 'c15t/astro';

export default defineConfig({
	integrations: [svelte(), c15t({ mode: hosted() })],
});
```

Astro builds every page once. At build time there is no visitor, so
`ConsentBanner` renders an empty, hidden placeholder. In the browser, c15t
reads the visitor's consent cookie, calls your backend's `/init` directly, and
renders the banner into the placeholder when the policy says to show one. A
returning visitor keeps their choice, because it comes from their own cookie.

The browser calls the backend from your site's origin, so that origin must be
in the Inth project's trusted origins.

The default `manifest()` does not work here. It injects an on-demand route,
and without an adapter `astro build` stops with an error that names it.

## Static output with browser resolution

`manifest({ resolve: 'browser' })` resolves the policy in the browser from
your project's manifest, with no request to the backend's `/init`:

```js title="astro.config.mjs"
import svelte from '@astrojs/svelte';
import { defineConfig } from 'astro/config';
import c15t, { manifest } from 'c15t/astro';

export default defineConfig({
	integrations: [svelte(), c15t({ mode: manifest({ resolve: 'browser' }) })],
});
```

The build downloads the manifest and the integration prerenders it as a
static file at `/api/c15t/manifest`, so no adapter is needed. The browser
fetches that file from your own origin, loads the resolver, and resolves the
policy. It has no request location, so without `inputs` or `geoURL` every
visitor gets the policy your project assigns when the location is unknown.
Use `hosted()` when your policy differs by region and you have no location
source. Consent saves go to the backend, so your site's origin must be in
the Inth project's trusted origins.

## Server output with `manifest()`

```js title="astro.config.mjs"
import node from '@astrojs/node';
import svelte from '@astrojs/svelte';
import { defineConfig } from 'astro/config';
import c15t from 'c15t/astro';

export default defineConfig({
	adapter: node({ mode: 'standalone' }),
	integrations: [svelte(), c15t()],
	output: 'server',
});
```

The integration registers a middleware that runs before every page. It reads
the visitor's consent cookie, location headers and Global Privacy Control
signal, resolves the policy, and stores the result in `Astro.locals.c15t`. The
components render from it, so the banner is part of the HTML and a visitor who
has already chosen gets no banner markup at all. Such a page holds one
visitor's decision, so keep it out of shared caches. See
[Server API](/docs/frameworks/astro/server#cache-server-rendered-pages-safely).

`manifest()` resolves each request on the server from your project's public
policy file. By default the build downloads that file and bundles it, so a
render never waits on the backend for the policy. With
`manifest({ source: 'runtime' })`, in `astro dev` when the fetch fails, or
with `onBuildError: 'runtime'`, the server downloads and caches the file at
runtime. The browser asks the injected `/api/c15t/init` route when it needs a
decision, and saves consent to the backend URL. A server-rendered page ships
only the code that saves consent; the code that resolves a policy loads when
a page asks again. `manifest()` needs the backend URL as `backendURL` or
`PUBLIC_C15T_BACKEND_URL` (or `PUBLIC_INTH_PROJECT_URL`); without it, the
config fails to load.
[Data fetching](/docs/concepts/data-fetching) compares this with calling
`/init` for every render.

`hosted()` also works with server output. Every server render then calls the
backend's `/init`, so each page view waits on a backend request.

### Bundle the manifest during builds

The build fetches your public policy once and bundles it, so the server never
fetches it at runtime.

`manifest()`, the default mode, bundles the manifest. The integration fetches
it when `astro build` or `astro dev` starts, then embeds it in its generated
server options. The middleware and the consent route use that same snapshot.
The browser options omit it; only `manifest({ resolve: 'browser' })` serves
it to the browser, as the prerendered `/api/c15t/manifest`. No
generated file or extra import is needed. `astro check` and `astro sync` do
not fetch, so type checks work without the backend. `astro preview` serves the
completed build and never fetches a new snapshot.

The build fetches only from an absolute upstream backend URL or
`manifestURL`, because it cannot fetch a route in the app it is still
compiling. `c15t()` without a `backendURL` reads `PUBLIC_C15T_BACKEND_URL`, then
`PUBLIC_INTH_PROJECT_URL`.
With a relative URL, `manifest({ source: 'runtime' })`,
`manifest({ snapshot })`, `hosted()` or `offline()`, the integration skips
the fetch. If the fetch fails, or no backend URL is set, `astro build` stops.
`astro dev` logs a warning instead, and the server fetches and caches the
policy at runtime. The config is read once at build time, so the built server
keeps the backend URL and the snapshot it was built with.

To let a build continue when the snapshot can't be fetched, set
`onBuildError: 'runtime'`, or build with `C15T_ON_BUILD_ERROR=runtime`:

```js title="astro.config.mjs"
c15t({ onBuildError: 'runtime' });
```

`onBuildError: 'fail'` stops `astro dev` too, and stops the build on a
relative URL.

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](/docs/concepts/modes#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:

|Command|Default when the fetch fails|
|--|--|
|Production build: `next build`, `vite build`, `nuxt build`, `astro build`|The build stops with an error.|
|Dev: `next dev`, `vite dev`, `nuxt dev`, `astro dev`|A 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:

```sh
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](/docs/concepts/modes#what-happens-when-the-download-fails)
lists every case.

For policy updates without a rebuild, set `mode: manifest({ source: 'runtime' })`.
The server then fetches and caches the policy at runtime.

### Set how long a render waits for the backend

The middleware waits up to 500 ms for the policy. With a bundled manifest the
policy is already on the server, so the budget matters when the server
fetches it at runtime or uses `hosted()`. If the backend has not answered by
then, for example on a cold cache or during an outage, the page
renders without a server decision. The HTML has no banner, optional categories
are denied, and gated scripts and iframes stay blocked. The browser then asks
for the policy and shows the banner when it arrives.

Change the budget in the integration options:

```js title="astro.config.mjs (partial)"
c15t({ middleware: { timeoutMs: 800 } });
```

Set `timeoutMs: false` to wait however long the backend takes. With
`hosted()`, keep the budget above the backend's usual `/init` response time.

### Skip routes that never render consent

The middleware resolves consent for every route, including your API routes.
List routes that should not pay for that, such as health checks or webhooks.
A prefix also covers the routes below it, and `Astro.locals.c15t` stays unset
on a skipped route:

```js title="astro.config.mjs (partial)"
c15t({ middleware: { skip: ['/api/webhooks', '/healthz'] } });
```

The integration's own route under `routePrefix` is always skipped.

## Prerender pages in a server build

Add `prerender` to a page's frontmatter to build it once in a server build:

```astro title="src/pages/about.astro (partial)"
---
export const prerender = true;
---
```

The middleware sees that the page is prerendered and does not read the build's
request headers or cookies. The page then behaves like a page from a static
build. The browser reads the visitor's cookie, requests the policy from
`/api/c15t/init` and renders the banner. Other pages still render per request.

If you call `resolveConsentContext` from `c15t/astro/server` yourself, pass
`prerendered: true` only for output that every visitor shares. It drops the
request's cookie and location, so do not use it to skip the backend for one
visitor's render.

## Cache a page and render the banner per request

A page served from a CDN cache or prerendered once can still show the right
banner to each visitor. Replace `ConsentBanner` with `ConsentBannerDeferred`,
which renders the banner in an Astro server island:

```astro title="src/layouts/cached.astro"
---
import { ClientRouter } from 'astro:transitions';
import {
	ConsentDialog,
	ConsentDialogLink,
	ConsentScript,
} from 'c15t/astro/components';
import ConsentBannerDeferred from 'c15t/astro/components/consent-banner-deferred.astro';

interface Props {
	title: string;
}

const { title } = Astro.props;
---

<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta content="width=device-width, initial-scale=1" name="viewport" />
		<title>{title}</title>
		<ConsentScript />
		<ClientRouter />
	</head>
	<body>
		<slot />
		<footer>
			<ConsentDialogLink>Privacy settings</ConsentDialogLink>
		</footer>
		<ConsentBannerDeferred />
		<ConsentDialog />
	</body>
</html>
```

The page HTML stays the same for everyone. The island's request carries the
visitor's cookie and location, so the middleware resolves consent for it and
the banner arrives with the visitor's own policy. Server islands need an
adapter. On a static host with no adapter, use `ConsentBanner` and let the
browser render it.

## Keep consent across `ClientRouter` navigation

With Astro's `ClientRouter`, the browser swaps pages without reloading scripts.
c15t creates one consent runtime per page load and keeps it across swaps. After
each swap it re-attaches to the new page's banner, gated scripts and dialog
triggers, applies the color scheme again, and keeps any stylesheet it linked
for the dialog. The
visitor's choice and an open dialog both carry over, and no new `/init`
request is made.

Your own scripts run once per page load, like c15t's. To update markup after a
swap, listen for `astro:page-load`. See the
[client API](/docs/frameworks/astro/client-api).

## Develop without a backend

`offline()` resolves policies in the browser and on your server with no
backend. Not recommended for production environments.

```js title="astro.config.mjs (partial)"
c15t({ mode: offline() });
```

Import `offline` from `c15t/astro`. Choices persist in the visitor's cookie and
localStorage only. There are no consent records, no hosted policies and no
visitor counts. Pass `policyRules` to `offline()` to test a specific policy.

On a prerendered page, offline mode resolves the policy at build time because
it needs no request. The banner is in the HTML but hidden, and a small inline
script shows it in the first paint to visitors with nothing stored.

## Taps before hydration

A banner in the server HTML shows before the page's JavaScript runs. A tap on
it in that gap does nothing: the button has no handler yet, so the banner
stays up and no choice is saved. The React and Vue banners hold such a tap and
replay it after hydration; the Astro banner does not.

## Check the rendering path

* **Server output:** view the page source of a first visit. It contains
  `data-testid="consent-banner-root"`. After you choose and reload, it does not.
  In DevTools Network, the browser does not request `/init` on a server-rendered
  page.
* **Static output or a prerendered page:** the page source has no banner
  markup. In DevTools Network, the browser requests `/init` from your backend
  with `hosted()`, `/api/c15t/manifest` with
  `manifest({ resolve: 'browser' })`, or `/api/c15t/init` in a server build,
  and the banner appears after it returns.
* **Server island:** the page source has no banner markup. In DevTools
  Network, a request to `/_server-islands/ConsentBanner` returns it.
* **Every path:** vendor requests stay absent until you allow their category,
  and a rejection survives a reload. See
  [Verify consent](/docs/guides/verify-consent).
