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 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. 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 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:
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()
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:
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:
| 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:
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:
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:
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:
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:
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.
Develop without a backend
offline() resolves policies in the browser and on your server with no
backend. Not recommended for production environments.
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/initon a server-rendered page. - Static output or a prerendered page: the page source has no banner
markup. In DevTools Network, the browser requests
/initfrom your backend withhosted(),/api/c15t/manifestwithmanifest({ resolve: 'browser' }), or/api/c15t/initin 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/ConsentBannerreturns it. - Every path: vendor requests stay absent until you allow their category, and a rejection survives a reload. See Verify consent.