---
title: Headless
description: Build your own consent banner and preference form in Nuxt with the
  auto-imported c15t composables instead of ConsentRoot.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## When to go headless

Tokens, `presentation` and `components` slots change most designs without
new markup. See [customize](/docs/frameworks/nuxt/customize) first. Go
headless when the consent UI has to be your own markup, such as a design
system component.

Headless changes only the UI. The Nuxt module still resolves the policy,
stores choices, loads scripts from `app.config.ts` and gates iframes.

## Write the consent UI

This component shows a banner while `activeUI` is `'banner'` and a preference
form while it is `'manager'`. The module auto-imports the composables.

```vue title="app/components/ConsentPrompt.vue"
<script setup lang="ts">
const activeUI = useConsentActiveUI();
const saveConsent = useConsentSave();
const { displayedCategories, save: saveDraft, values } = useConsentDraft();

// Recording a choice satisfies the prompt, so c15t closes the banner itself.
const choose = (choice: 'all' | 'none') => {
	void saveConsent(choice);
};

// Preferences stay open until you close them. c15t records the draft
// locally before the network request, so closing right away is safe.
const savePreferences = () => {
	void saveDraft();
	activeUI.value = null;
};
</script>

<template>
	<section
		v-if="activeUI === 'banner'"
		class="consent-prompt"
		aria-label="Cookie consent"
	>
		<p>We use cookies for measurement and marketing.</p>
		<button type="button" @click="choose('none')">Reject all</button>
		<button type="button" @click="choose('all')">Accept all</button>
		<button type="button" @click="activeUI = 'manager'">Customize</button>
	</section>
	<section
		v-else-if="activeUI === 'manager'"
		class="consent-prompt"
		aria-label="Privacy settings"
	>
		<label v-for="category in displayedCategories" :key="category">
			<input
				v-model="values[category]"
				type="checkbox"
				:disabled="category === 'necessary'"
			/>
			{{ category }}
		</label>
		<button type="button" @click="savePreferences">Save</button>
	</section>
</template>
```

`useConsentSave()` records Accept all or Reject all. Recording a choice
satisfies the prompt, so c15t sets `activeUI` to `null` and the banner closes.
`useConsentDraft()` holds the preference form. `displayedCategories` lists the
categories the policy lets the visitor choose, `values` holds each switch, and
`save()` records them. Preferences stay open after a save, so close them
yourself. [Composables](/docs/frameworks/nuxt/composables#build-a-preference-form)
lists every field of the draft.

Add focus handling, a close control and styles before you ship this. The
component keeps the bare markup so the consent logic is easy to read.

## Mount it instead of ConsentRoot

Replace `<ConsentRoot />` in `app.vue` with your component. Keep one way to
reopen preferences, such as `ConsentDialogLink`.

```vue title="app/app.vue"
<template>
	<ConsentPrompt />
	<NuxtPage />
	<footer>
		<ConsentDialogLink />
	</footer>
</template>
```

When the server resolves the policy, in the default `manifest()` mode or `hosted()`, the component
renders on the server with the visitor's policy, like `ConsentRoot`.

## Show the buttons the policy requires

A policy can require buttons and forbid others. For a notice, it requires a
dismiss action instead of Accept and Reject. Read the resolved actions with
`useConsentPolicyActions('prompt')` and render `primaryActions` and
`actionGroups` from it, rather than hard-coding the buttons. Call
`useDismissNotice()` for a notice's dismiss action. Dismissing records an
acknowledgement, not consent.

`usePromptRequirement()` tells you whether the policy asks for a choice, a
notice or nothing.

## Verify the custom UI

Run through [verify consent](/docs/guides/verify-consent) with your
component. Check that Reject all is as easy to reach as Accept all, that the
preference form lists the policy's categories, and that a rejection survives a
reload.
