Skip to main content

Next.js Customization

Headless

When to go headless

The pre-built banner and dialog cover most designs through props, slots, and the stylesheet. Go headless when your markup has to be something else entirely: a design system component, a native sheet, or a layout the compound parts cannot express.

Headless code owns the rendered controls, so it also owns the compliance outcome. The hooks hand you the actions a policy requires, the rights it must keep reachable, and diagnostics when your presentation drops one. Render from those lists rather than from a fixed set of buttons, and a site that later adds a notice region keeps working without a code change.

Check for a policy before rendering your own surfaces. useModel() from c15t/next returns null while no rule has resolved and 'none' under a rule that owes no consent UI, and the pre-built surfaces render nothing in that state.

Minimal example

The headless hooks read the runtime from the ConsentRoot set up in your App Router or Pages Router guide, so they only run in a Client Component. Render this component inside that existing root in place of the stock banner. Keep the dialog and persistent preferences control.

components/consent-banner.tsx
'use client';

import { useHeadlessConsentUI, useTranslations } from 'c15t/next/headless';

const ACTION_LABELS = {
	accept: 'acceptAll',
	reject: 'rejectAll',
	customize: 'customize',
	dismiss: 'acknowledge',
	save: 'save',
} as const;

export function Banner() {
	const { banner, performAction, openDialog } = useHeadlessConsentUI();
	const { common, rights } = useTranslations();

	if (!banner.isVisible) {
		return null;
	}

	return (
		<section role="region" aria-label="Privacy">
			{banner.preferenceControls.map((right) => (
				<button key={right} type="button" onClick={openDialog}>
					{right === 'opt-out' ? rights?.optOut : rights?.preferences}
				</button>
			))}
			{banner.actionGroups.map((group) => (
				<div key={group.join('-')}>
					{group.map((action) => (
						<button
							key={action}
							type="button"
							data-primary={banner.primaryActions.includes(action) || undefined}
							onClick={() => performAction(action)}
						>
							{common[ACTION_LABELS[action]]}
						</button>
					))}
				</div>
			))}
		</section>
	);
}

banner.actionGroups is the resolved layout: reject and accept share a group at equal prominence, and the rest follow. banner.orderedActions is the same list flattened. Under a notice the only action is dismiss, and banner.preferenceControls recommends additional buttons for opening preferences. Under a notice it contains opt-out, which selects the "Do not sell or share my data" label. The example renders that button and an "OK" button. Both controls keep their own command: opening preferences and dismissing the notice.

The list is a rendering helper. It does not establish that your UI implements all policy rights. Provide disclosure and persistent preferences access.

performAction saves all categories for accept, none for reject, the current draft for save, records a dismissal for dismiss, and opens the preference center for customize. banner.diagnostics reports when a host layout drops a required action or gives equivalent actions different prominence. Review each diagnostic when configuring custom presentation.

banner.variant, banner.position, and banner.blocking carry the resolved shape from presentation.prompt, so a headless surface can follow the same bar, widget, or wall choice the pre-built banner would make, and can trap focus and lock scroll exactly when blocking is true.