Skip to main content

Customization

Banner experiments

Vary presentation, not policy

An experiment changes only how the prompt and the preference center look: variant, position, layout, primary actions, blocking. The policy rule, its categories and its copy stay the same for every arm, so every arm records a choice under the same policy fingerprint.

Your normal presentation is the control arm; without one, control is the stock banner. Every other arm lists only what it changes. Add the experiment to the provider you already have and pass the arm your feature flag resolved. In the React example, the Consent wrapper takes the arm as a prop:

src/consent.tsx
import { defineExperiment } from 'c15t';
import {
	ConsentBanner,
	ConsentDialog,
	ConsentDialogLink,
	ConsentProvider,
	hosted,
} from 'c15t/react';
import type { ConsentProviderCallbacks } from 'c15t/react';
import type { ReactNode } from 'react';

import { scripts } from './scripts';

const mode = hosted({ backendURL: 'https://your-project.inth.app' });

// `control` is the stock banner. `wall` blocks the page until the visitor
// chooses.
const bannerExperiment = defineExperiment({
	arms: { wall: { prompt: { variant: 'wall' } } },
	id: 'banner-shape',
});

const pushToDataLayer = (event: Record<string, unknown>) => {
	const page = window as Window & { dataLayer?: unknown[] };
	page.dataLayer ??= [];
	page.dataLayer.push(event);
};

// Forward each impression and choice made under an arm to your analytics.
const callbacks = {
	onChoiceRecorded: ({ consentAction, experiment }) => {
		if (experiment) {
			pushToDataLayer({
				arm: experiment.arm,
				consent_action: consentAction,
				event: 'c15t_choice_recorded',
				experiment_id: experiment.id,
			});
		}
	},
	onSurfaceShown: ({ experiment, surface }) => {
		if (experiment) {
			pushToDataLayer({
				arm: experiment.arm,
				event: 'c15t_surface_shown',
				experiment_id: experiment.id,
				surface,
			});
		}
	},
} satisfies ConsentProviderCallbacks;

export const Consent = ({
	arm,
	children,
}: {
	/**
	 * The arm your flag provider resolved. Omit it to let c15t pick, or pass
	 * `off` to leave this visitor out of the experiment.
	 */
	arm?: 'control' | 'wall' | 'off';
	children: ReactNode;
}) => (
	<ConsentProvider
		options={{
			callbacks,
			experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
			mode,
			scripts,
		}}
	>
		{children}
		<ConsentBanner />
		<ConsentDialog />
		<footer>
			<ConsentDialogLink>Privacy settings</ConsentDialogLink>
		</footer>
	</ConsentProvider>
);

defineExperiment() from c15t returns its argument and has TypeScript check the arm names in arm and split. The callbacks forward each impression and choice to window.dataLayer; see send the events to your own analytics.

Your flag provider returns a plain string. To check it before passing it as arm, type it with ExperimentArmName from c15t, which is control plus the experiment's arm names:

import type { ExperimentArmName } from 'c15t';

// 'control' | 'wall'
type BannerArm = ExperimentArmName<typeof bannerExperiment>;

const armFromFlag = (value: string | undefined): BannerArm | undefined =>
	value === 'control' || value === 'wall' ? value : undefined;

Omit arm to let c15t pick, and set split to weight the arms:

experiment: { ...bannerExperiment, split: { control: 60, wall: 40 } }
FieldPurpose
idStable experiment name, recorded with every choice
armsWhat each arm changes, merged over presentation. control is your presentation and is not listed
armThe arm your flag resolved: control or a key of arms
splitRelative weights when c15t picks the arm; default equal. Keys are control and the arms
acknowledgeDiagnosticsRun an arm that trips a presentation diagnostic and record that you reviewed it

c15t merges the arm over presentation, exposes it as snapshot.experiment, sends it with /init, and records it on the choices of visitors the banner showed it to, so you can compare opt-in rate and time to decision per arm.

The same option exists in c15t/next (ConsentRoot options), c15t/vue, @c15t/svelte, the c15t() Astro integration from c15t/astro, and @c15t/browser. On a server-rendered page in Next.js, TanStack Start or SvelteKit, pass it to resolveConsent instead, which counts the arm and hands it to the client; see Vercel Flags SDK.

experiment is read once, when the provider mounts. Changing it later has no effect; remount the provider (a key in React) to switch experiments.

Assignment and arm validation load as a separate chunk, only on pages that set experiment. A site without an experiment does not download them.

An experiment compares choices, so the banner has to ask about at least one category. Under a rule with scopeMode: 'permissive', the banner asks only about the categories your site declares through consentCategories, scripts or vendors. With none declared, it asks about none: accepting or rejecting records a notice acknowledgement instead of a choice, onChoiceRecorded never fires, and the experiment counts impressions without choices. Outside production, c15t logs a warning the first time the banner shows an arm in that state.

Astro

The Astro banner is server-rendered HTML that the browser only shows or hides, so the arm is resolved on the server. To pick it per request, set middleware: false in c15t() and compose the consent middleware yourself with experimentArm, where bannerExperimentFlag stands for your flag lookup:

src/middleware.ts
import { consentMiddleware } from 'c15t/astro/middleware';

export const onRequest = consentMiddleware({
	experimentArm: async (context) => await bannerExperimentFlag(context),
});

Return undefined to run no experiment for that request. A fixed experiment.arm in c15t() puts every visitor in one arm, which only suits a staged rollout. Prerendered routes render once for every visitor, so they get no per-request arm. c15t() throws at config time when experiment has neither.

Vary the theme

An arm can carry theme overrides next to its presentation fragment. They merge over the host theme one token group deep, so an arm can change one colour or radius and keep the rest of your palette. Arrays and scalars are replaced.

experiment: {
	id: 'button-style',
	arms: {
		bold: {
			theme: {
				colors: { primary: '#0a0a0a' },
				radius: { lg: '4px' },
			},
		},
	},
}

Read the merged theme with useResolvedTheme() in React, useResolvedTheme(theme) in Vue, getConsentManager().theme in Svelte and client.theme in @c15t/browser. Astro renders the arm's tokens with the banner. In React, consentActions and slot overrides apply through the provider, but colour, radius and other tokens reach the page through ConsentTheme. Render it from the resolved theme in a client component inside the provider, with both imported from c15t/react:

<ConsentTheme theme={useResolvedTheme()} />;

Rendered from a client component, ConsentTheme ships the theme generator. With an arm from your flag, you can instead render <ConsentTheme theme={resolveExperimentTheme(theme, experiment, { arm })} /> from a Server Component. Per-action styling through theme.consentActions runs through the same prominence check as presentation: an arm that fills accept and outlines reject trips equivalent-prominence-overridden and needs acknowledgeDiagnostics: true.

Resolve the arm with a flag provider

Resolve the arm wherever your flags live and pass its name as arm. c15t records assignedBy: 'host' and never re-assigns a visitor you assigned.

Vercel Flags SDK

Give the flag three values. off keeps a visitor out of the test; control and wall split the rest. Set the weights in the Vercel dashboard, so you can roll out and widen the test without a deploy: start at 90% off, check that both arms arrive, then move to 0% off for the real run.

src/flags.ts
import { vercelAdapter } from '@flags-sdk/vercel';
import { flag } from 'flags/next';

export const bannerExperimentFlag = flag<'off' | 'control' | 'wall'>({
	key: 'banner-shape',
	adapter: vercelAdapter(),
	identify, // your existing identify
	defaultValue: 'off',
});

Keep identify returning a stable id. Vercel splits on it, so a visitor keeps their arm when you change the weights.

Resolve the flag where consent resolves and pass the experiment to resolveConsent. The server reports the arm with /init, and the returned state carries the experiment to ConsentRoot, so the client needs no experiment option of its own. In Next.js, do this in the root layout:

app/layout.tsx
import { resolveConsent } from 'c15t/next/server';
import { Suspense } from 'react';
import type { ReactNode } from 'react';

import { ExperimentConsent } from '@/components/experiment-consent';
import { bannerExperiment } from '@/lib/experiment';
import { bannerExperimentFlag } from '@/lib/flags';

import '@/styles/globals.css';

const ResolvedConsent = async ({ children }: { children: ReactNode }) => {
	const arm = await bannerExperimentFlag();
	const state = await resolveConsent({
		// `off` keeps this visitor out of the experiment.
		experiment: arm === 'off' ? undefined : { ...bannerExperiment, arm },
	});

	return <ExperimentConsent state={state}>{children}</ExperimentConsent>;
};

const RootLayout = ({ children }: { children: ReactNode }) => (
	<html lang="en">
		<body>
			<Suspense fallback={null}>
				<ResolvedConsent>{children}</ResolvedConsent>
			</Suspense>
		</body>
	</html>
);

export default RootLayout;

@/lib/flags exports the flag above. ExperimentConsent is the Consent wrapper from the App Router guide with the event callbacks from send the events to your own analytics in options. The layout awaits consent inside <Suspense>, as in render the banner in the server HTML, so the banner is in the server HTML already showing the visitor's arm. resolveConsent in c15t/tanstack-start/server and @c15t/svelte/server takes the same experiment.

If you pass resolveConsent() to ConsentRoot without awaiting it (the streaming layout), the client mounts before the state arrives. Pass the same experiment to the client options as well; c15t warns in development when the streamed state carries an experiment the client did not get.

PostHog

PostHog resolves flags asynchronously in the browser. Because experiment is read once at mount, wait for the flags, then mount the provider with the resolved arm. Do not wait forever: a blocked or slow flags request would leave the visitor with no banner and you with no consent. After a second, mount without the experiment. Those visitors see presentation and are not counted in either arm. This component wraps the Consent component from the first example:

src/flagged-consent.tsx
import posthog from 'posthog-js';
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';

import { Consent } from './consent';

export const FlaggedConsent = ({ children }: { children: ReactNode }) => {
	const [arm, setArm] = useState<'control' | 'wall' | 'off' | null>(null);
	useEffect(() => {
		// The first answer wins: the provider reads the arm once.
		const fallback = setTimeout(
			() => setArm((current) => current ?? 'off'),
			1000
		);
		const unsubscribe = posthog.onFeatureFlags(() => {
			const flag = posthog.getFeatureFlag('banner-shape');
			setArm((current) => current ?? (flag === 'wall' ? 'wall' : 'control'));
		});
		return () => {
			clearTimeout(fallback);
			unsubscribe();
		};
	}, []);
	if (arm === null) {
		// Flags not loaded yet: no provider, no banner.
		return children;
	}
	return <Consent arm={arm}>{children}</Consent>;
};

LaunchDarkly, GrowthBook, Statsig

const arm = ldClient.stringVariation('banner-shape', 'control');
const arm = growthbook.getFeatureValue('banner-shape', 'control');
const arm = StatsigClient.instance()
	.getExperiment('banner-shape')
	.get('arm', 'control');

An arm that is not control or a key of arms logs an error and runs no experiment for that visitor; the page still renders. Treat that as a flag misconfiguration.

Let c15t assign the arm

Omit arm and c15t picks one by split (equal by default) when the page starts, before /init, and records assignedBy: 'c15t'. A visitor who already saw an arm keeps it.

The banner waits until the arm is picked, so the visitor never sees the base banner swap for their arm. On a server-rendered page that means the banner is not in the server HTML; it appears once the browser has loaded the assignment chunk. If the chunk fails to load, the base banner shows and no experiment runs. To keep the banner in the server HTML, resolve the arm on the server and pass it as arm.

Once the banner has shown the arm, c15t stores { id, arm } under c15t-experiment-v1 in localStorage (a cookie when localStorage is unavailable), so the visitor keeps seeing the banner they saw. Nothing is stored for a visitor who is never prompted, and nothing is stored for a host-resolved arm: your flag provider decides that one on every visit. The record holds no identifier. It exists only to keep the consent banner consistent, so treat it like the consent record itself when you describe your storage.

A split that gives no arm a positive weight, or that names an arm that does not exist, logs an error and runs no experiment. An arm missing from the split gets no visitors.

experiment: {
	id: 'banner-shape',
	arms: { wall: { prompt: { variant: 'wall' } } },
	split: { control: 60, wall: 40 },
}

Changing id starts a new experiment and re-assigns everyone. Removing an arm re-assigns only the visitors who were in it. For a clean analysis, change id rather than editing the arms of a running experiment.

Built-in assignment is not available in Astro; see Astro.

A Vite app's early /init script, from consentManifest(), picks the arm the same way when you pass it the experiment, and the app then runs that arm. Import the same object into vite.config.ts:

vite.config.ts
consentManifest({ initPrefetch: { experiment: bannerExperiment } });

Without it, the app sends its own /init for visitors who have not chosen yet. The same goes for consentPrefetchHead in TanStack Start and buildPrefetchScript elsewhere, which take the same experiment option.

When a feature flag picks the arm, leave experiment out of initPrefetch. The script would pick its own arm and send it to the backend, and the app would then send a second /init with the flag's arm.

Acknowledge presentation diagnostics

Each arm is resolved under the visitor's policy the same way presentation is. An arm that trips a diagnostic, for example equivalent-prominence-overridden because it makes accept primary while reject stays neutral, is not shown under that policy: those visitors see the base presentation, are not counted in the experiment, and c15t logs the diagnostics. Set acknowledgeDiagnostics: true to run the arm anyway. c15t then logs the diagnostics as a warning once per policy and records acknowledgedDiagnostics: true with the arm on every choice. You own the legal review of that arm; c15t records that you made it.

The check runs in the browser once the policy is known, so catch a rejected arm before you deploy with validateExperiment in a test. euPolicy and presentation stand for the policy and presentation your site uses:

import { validateExperiment } from 'c15t/experiment';

test('banner-shape arms pass under the EU policy', () => {
	validateExperiment(bannerExperiment, euPolicy, { presentation });
});

It throws for an invalid definition and for arms with unacknowledged diagnostics.

Read the assignment

import {
	useExperiment,
	useResolvedPresentation,
	useResolvedTheme,
} from 'c15t/react';

const { id, arm, assignedBy } = useExperiment() ?? {};
const presentation = useResolvedPresentation();
const theme = useResolvedTheme();

React exposes three hooks:

  • useExperiment() returns the assigned arm (id, arm, assignedBy, acknowledgedDiagnostics), or null while no experiment is configured or no arm is assigned yet.
  • useResolvedPresentation() returns presentation with the assigned arm merged over it per surface. While no arm is assigned it returns presentation itself. The stock banner, dialog and widget render from it, as do usePromptPresentation() and usePreferencesPresentation().
  • useResolvedTheme() returns theme with the arm's theme merged one token group deep over it. While no arm is assigned, or the arm has no theme, it returns theme itself. The provider injects this merged theme.

Built-in assignment lands after mount, so useExperiment() is null on the server render and during hydration and the resolved hooks return the base values. The banner is held until then, and a clean preferences draft reseeds from the arm's preferences.defaults when it lands. An arm from your flag is known from the first render, on the server too.

Vue exposes useExperiment(), useResolvedPresentation() and useResolvedTheme(theme), Svelte getConsentManager().experiment, .presentation and .theme, and @c15t/browser client.presentation and client.theme. Every adapter also reports the arm on snapshot.experiment.

What is recorded

An impression or a choice carries the arm only once the banner has shown it in the current page. A returning visitor who reopens the preference center from a footer link, or saves from an inline widget, never saw the arm's banner, so their choice is recorded without it and does not count toward any arm.

Each choice saved through POST /subjects carries in metadata:

KeyValue
experiment{ id, arm, assignedBy, acknowledgedDiagnostics }
timeToDecisionMsMilliseconds from the surface's first impression to the action

uiSource on the same record names the surface (banner, dialog, widget). The surface:shown and choice:recorded kernel events and the onSurfaceShown and onChoiceRecorded callbacks carry the same experiment object, so impressions and decisions can be joined per arm in your analytics without a backend query.

Measure the results

An opt-in rate needs two counts per arm: how many visitors the banner was owed to, and how many of them accepted. c15t sends both to the backend on its own; you do not wire anything up.

  • Visitors owed the banner. While a visitor has no stored choice, every /init carries their arm as <id>=<arm> in the experiment query parameter, and the backend puts it on that request's session report as experiment: { id, arm }. In manifest mode, the server render or the init route puts it on the report it sends to POST /sessions. A visitor who already chose is not shown the banner, so is not counted. A server-to-server caller can send the x-c15t-experiment header instead.
  • Choices. Every choice saved through POST /subjects carries the arm in metadata.experiment, as above.

On a server-rendered page the server calls /init, not the browser, so the server has to know the arm. Pass the experiment to resolveConsent, as in Vercel Flags SDK: it sends only { id, arm } to the backend and hands the full experiment to the client in its state. resolveConsent takes experiment in c15t/next/server, c15t/tanstack-start/server and @c15t/svelte/server; Astro and Nuxt pass the arm they rendered on their own. When c15t picks the arm in the browser, the browser's own /init carries it, so a server-rendered page that skips the client /init has no count for built-in assignment. Resolve the arm with a flag on those pages.

Where the counts go

On Inth, the dashboard reads both counts for you. A self-hosted backend hands each session report to sessions.onReport, which is where you log or count it; experiment is on the report. Choices are in the consent table, and GET /experiments/:id/summary groups them per arm.

The opt-in rate of an arm is visitors with an accept_all choice under that arm, divided by visitors whose session reports carry the arm. Count visitors, not requests: an undecided visitor sends a report on every page until they choose. Deduplicate the reports per visitor the way you count sessions.

Send the events to your own analytics too

The onSurfaceShown and onChoiceRecorded callbacks carry the same experiment object, so a few lines forward them to any tool. The React example pushes both to window.dataLayer for Google Tag Manager, as c15t_surface_shown with the surface and c15t_choice_recorded with the consent action. To send them to PostHog instead, call posthog.capture() with the same fields in the callbacks. The callbacks run for every impression and choice; check experiment first, because it is undefined for visitors outside the test.

An impression fires before any consent exists. If your analytics tool loads only after consent, it never sees a decliner's impression, and its opt-in rate trends towards 100%. The backend counts above do not have that gap.

Read the results

Consent rates move by a few points, not by half. At a 30% base rate you need roughly 2,500 visitors per arm to detect a 5-point change with 80% power, and about 10,000 per arm to detect 2 points. Run a sample-size calculator against your own base rate before you start, decide the stop date up front, and do not stop early on a good-looking day.

Under an opt-out policy with prompt: 'notice', dismissing the notice records no choice, so compare arms on opt-outs instead: opt_out choices per visitor owed the banner.

Time to decision is the median timeToDecisionMs per arm over the choices. Use the median, not the mean: a visitor who leaves the tab open skews the mean without saying anything about the arm.

Read the summary from your backend

The self-hosted backend copies experiment.id, experiment.arm and timeToDecisionMs out of metadata onto their own columns (migration 6-experiment-attribution), so GET /experiments/:id/summary can group choices per arm without a JSON query. It needs an API key.

curl 'https://app.example.com/api/c15t/experiments/banner-shape/summary?from=2026-09-01&to=2026-09-30' \
  -H "Authorization: Bearer $C15T_API_KEY"
{
	"experimentId": "banner-shape",
	"from": "2026-09-01T00:00:00.000Z",
	"to": "2026-09-30T23:59:59.999Z",
	"arms": [
		{
			"arm": "wall",
			"choices": 120,
			"byAction": {
				"accept_all": 80,
				"custom": 10,
				"opt_out": 0,
				"reject_all": 30,
				"unknown": 0
			},
			"bySurface": { "banner": 100, "dialog": 20 },
			"medianTimeToDecisionMs": 4200
		},
		{
			"arm": "control",
			"choices": 95,
			"byAction": {
				"accept_all": 50,
				"custom": 5,
				"opt_out": 0,
				"reject_all": 40,
				"unknown": 0
			},
			"bySurface": { "banner": 90, "dialog": 5 },
			"medianTimeToDecisionMs": 5100
		}
	]
}

byAction always has all five keys. They are the action the backend stored, not the client's consent_action: accept_all for accept, reject_all for reject, opt_out for a reject under an opt-out policy, custom for a saved selection, and unknown for a record without one. choices counts consent records, so a visitor who changes their mind under the same arm counts twice.

from and to accept an ISO 8601 date or timestamp and filter on the consent's givenAt, both ends inclusive. A date without a time is the whole of that day in UTC: from=2026-09-01 starts at midnight and to=2026-09-30 runs to the end of the 30th, so the example above covers all of September. A from later than to is a 400. domain narrows to one domain name. An experiment id no consent carries returns arms: [].

From server code, the Node.js SDK makes the same call. Create the client with an API key; from and to also take a Date:

const c15t = createC15tClient({
	baseUrl: 'https://app.example.com/api/c15t',
	apiKey,
});

const result = await c15t.experiments.summary('banner-shape', {
	from: '2026-09-01',
	to: '2026-09-30',
});

The summary counts choices. The other half of an opt-in rate, the visitors each arm's banner was owed to, is on the session reports /init produces (see Measure the results). Divide accept_all per arm by the visitors whose reports carry that arm.

Try it

Every example app under internals/fixtures/ runs this experiment outside the pages the docs publish. Each page shows the assigned arm and the events the callbacks sent. Adding arm=wall sets the arm the way a flag would, so the page shows assignedBy: host. Run each command in the example's directory.

ExampleStartOpen
Next.jsbun run dev/experiment, with the arm from a stand-in flag: ?arm=wall, ?arm=off to leave the test, control otherwise
TanStack StartC15T_EXPERIMENT=1 bun run dev/consent-example?experiment=1, then add &arm=wall
Reactbun run dev/experiment.html, then add ?arm=wall
NuxtC15T_NUXT_EXPERIMENT=1 bun run dev; add C15T_NUXT_EXPERIMENT_ARM=wall for the wall arm/consent-example
Vuebun run dev/?experiment=1, then add &arm=wall
AstroC15T_EXPERIMENT=1 bun run dev/consent-example?experiment=1 runs control, as Astro has no built-in assignment; add &arm=wall
Sveltebun run dev/?experiment=1, then add &arm=wall
SvelteKitbun run dev/experiment-example?experiment=1, then add &arm=wall
HTML script tagbun run dev/?experiment=1, then add &arm=wall
JavaScriptbun run dev/experiment/, then add ?arm=wall

End an experiment

Move the winning arm's fragment into presentation (and its theme into theme), then remove experiment. Visitors keep their consent; the stored arm is ignored once no experiment reads it.

Copy is out of scope

Arms change presentation and theme tokens only. Copy is not a variant dimension because copyRevision is hashed into the prompt fingerprint: a copy change re-prompts every returning visitor, so a copy experiment would re-prompt them on each arm switch. Vary layout, shape, position and action prominence instead.