Skip to main content

Astro

Quickstart

Before you start

Create an Inth project and set it up before you touch the code:

  1. Add policy rules that cover the measurement category. This guide loads PostHog on measurement.
  2. Add your site's origin to the project's trusted origins.
  3. Copy the project's backend URL.

Then decide how Astro builds your pages. The integration supports both, and Rendering and deployment covers mixed and cached setups.

Your siteAstro outputc15t mode
Static pages on any static host, no adapteroutput: 'static'hosted()
Pages rendered on each request, with a server adapteroutput: 'server'manifest(), the default

For server output, the setup below bundles your policy into the server at build time, the recommended production setup. Rebuild after changing policies, translations or vendors. With static output, the browser asks Inth for the policy on each visit.

examples/astro-static and examples/astro hold the finished code for each.

Install the packages

npm install c15t@alpha @astrojs/svelte svelte @c15t/integrations@alpha

The banner is plain .astro markup and ships no framework JavaScript. The preference dialog is an island that loads when a visitor first reaches for it. The c15t package includes the dialog for Svelte, React and Vue, and the integration renders it with the one of those three your site registers.

Use the framework your site already ships, so visitors do not download a second one for a single dialog. This guide uses Svelte. For React, install @astrojs/react react react-dom instead of @astrojs/svelte svelte and register react(). For Vue, install @astrojs/vue vue and register vue(). With more than one of them registered, set ui: 'react' or ui: 'vue' on c15t(); otherwise it uses Svelte.

Set the backend URL

Set PUBLIC_C15T_BACKEND_URL to your project's backend URL, including any path prefix, in .env or wherever you build:

.env
PUBLIC_C15T_BACKEND_URL=https://your-project.inth.app

If your app already sets PUBLIC_INTH_PROJECT_URL for other Inth SDKs, that works too. When both are set, PUBLIC_C15T_BACKEND_URL wins.

c15t() reads it from the environment, or from .env or .env.local in the project root, when astro.config.mjs loads, so the config needs no backendURL. The URL must be absolute.

Configure the integration

Pick the file that matches your output. Register the framework integration before c15t().

Static output

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() })],
});

hosted() sends the browser straight to your Inth backend's /init, so no server is needed. A static page is the same for every visitor, which means the banner appears once the browser's /init request returns, not in the first HTML.

Server output

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',
});

c15t() with no mode uses manifest(). It fetches your project's public policy once, when astro build or astro dev starts, and embeds it in the server build. The server resolves each visitor from that snapshot and the request's location and language headers, so the first HTML already contains the banner, or no banner at all for a visitor who has chosen. Consent saves still go to Inth. Data fetching compares this with calling the backend on every request.

manifest() injects one route, /api/c15t/[...path], which answers /api/c15t/init and /api/c15t/manifest, so it needs a server adapter. Use the adapter for your host in place of @astrojs/node. A server-rendered page ships only the code that saves consent; the code that resolves a policy in the browser loads when a page needs it.

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.

The built server keeps the backend URL from build time, so setting PUBLIC_C15T_BACKEND_URL when you start it changes nothing. Rebuild to point at another project. To apply policy edits without rebuilding, set mode: manifest({ source: 'runtime' }), with manifest imported from c15t/astro. The server then fetches and caches the policy at runtime.

The integration writes its options into the page as JSON. c15t() throws if an option holds a function, and names the option. Callbacks, vendor helpers and anything else with a function belong in src/c15t.client.ts. Keep secrets out of both.

Register vendor scripts

Remove any existing PostHog snippet first, so PostHog loads once. Then list the scripts in src/c15t.client.ts, which the integration finds on its own:

src/c15t.client.ts
import { posthog } from '@c15t/integrations/posthog';
import type { C15tClientOptionsExtension } from 'c15t/astro';

export default {
	scripts: [
		posthog({
			id: 'phc_your_project_key',
			initOptions: { cookieless_mode: 'never' },
			loadMode: 'after-consent',
		}),
	],
} satisfies C15tClientOptionsExtension;

Replace phc_your_project_key with your PostHog project key. PostHog waits for measurement permission and does not fall back to cookieless capture.

Other vendors work the same way. See integrations for the list, and Scripts, Embeds and Network blocker for inline scripts, iframes and tracking requests.

src/layouts/base.astro
---
import { ClientRouter } from 'astro:transitions';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentScript,
} from 'c15t/astro/components';

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>
		<ConsentBanner />
		<ConsentDialog />
	</body>
</html>

Wrap every page in this layout. ConsentScript goes in <head>, where it hands the browser the server's decision and sets the color scheme before first paint. ConsentBanner renders the banner, ConsentDialog reserves a place for the preference dialog, and ConsentDialogLink is the link visitors use to change their mind later. ConsentScript inlines the banner's rules into the page, so you do not import a stylesheet.

ClientRouter is optional. With it, the consent runtime survives page navigation. Without it, each page load starts the runtime again from the stored choice.

Type Astro.locals.c15t

The integration adds the type of the consent context it puts on every request to .astro/types.d.ts, so TypeScript knows Astro.locals.c15t without an env.d.ts line.

Check that it works

Build and preview the site, then open it in a private window with DevTools open on the Network tab:

  1. Before you choose, the banner shows and there are no requests to posthog.com.
  2. Select Reject All and reload. The banner stays closed and PostHog does not load.
  3. Open Privacy settings, turn on Analytics (the measurement category) and select Save Settings. PostHog loads.
  4. Turn Analytics off and save. The page reloads, and PostHog does not load again.

On server output, the page source of a first visit contains the banner markup. On static output it does not, and the banner appears after the /init request. Follow Verify consent before you ship.

Next steps