Skip to main content

Astro

Rendering and deployment

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 siteAstro setupc15t modeBanner in the first HTML
Static pages on any static hostoutput: 'static', no adapterhosted()No. The browser renders it after /init
Static pages, with the policy resolved in the browseroutput: 'static', no adaptermanifest({ resolve: 'browser' })No. The browser renders it after reading the manifest
Pages rendered on each requestoutput: 'server' and an adaptermanifest(), the defaultYes
A server build with some prerendered pagesoutput: 'server', prerender = true on those pagesmanifest()Only on the request-rendered pages
Cached pages that still need a per-visitor bannerAn adapter, and ConsentBannerDeferred in the layoutmanifest()Yes, from a server island
Local development and tests with no backendEither outputoffline()Depends on the output

Every path uses the same layout, client entrypoint and scripts from the quickstart. Only astro.config.mjs and, for cached pages, the banner component change. Choose your setup explains the trade-off across frameworks.

Static output with hosted()

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:

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()

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.

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 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:

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.

The fetch waits at most 10 seconds. When it fails, or no backend URL is set, every framework does the same thing:

CommandDefault when the fetch fails
Production build: next build, vite build, nuxt build, astro buildThe build stops with an error.
Dev: next dev, vite dev, nuxt dev, astro devA 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:

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 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:

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.

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:

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:

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:

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.

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.

Develop without a backend

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

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.