---
title: ConsentGate
description: Consent-gate an iframe in Next.js with ConsentGate inside
  ConsentRoot; with server prefetch the server HTML carries the placeholder for
  a denied category, and a granted embed mounts after hydration.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Gate an embed behind consent

`ConsentGate` mounts its children only while the effective permission for one
consent category is granted, and shows a placeholder otherwise. Wrap any
iframe or third-party widget that would set cookies or contact a vendor on
load. It is a Client Component; render it from any route under the layout
that mounts the `ConsentRoot` from your
[App Router](/docs/frameworks/next/app-router) or
[Pages Router](/docs/frameworks/next/pages-router) setup, which also mounts
the `ConsentDialog` the placeholder button opens.

```tsx title="components/product-video.tsx"
'use client';

import { ConsentGate } from 'c15t/next';

export function ProductVideo() {
	return (
		<ConsentGate category="marketing" className="video-frame">
			<iframe
				src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
				title="Product tour"
				loading="lazy"
				allowFullScreen
				style={{ width: '100%', aspectRatio: '16 / 9', border: 0 }}
			/>
		</ConsentGate>
	);
}
```

Import `ProductVideo` into any page or Server Component; only this file needs
`'use client'`. `marketing` must be in your policy scope. `ConsentGate`
registers its category when it mounts, so the preference center lists
Marketing even if you set `options.consentCategories` without it.

`ConsentGate` does not read the clock during server rendering, so a page that
renders it can still be prerendered, including with `cacheComponents: true`.
It does not need a `Suspense` boundary of its own. On a static page the
prerendered HTML contains the placeholder, and the embed replaces it after
the browser resolves policy and the category is allowed.

The placeholder names the category using its title from your translations. Set
`className` or `style` on `ConsentGate` to reserve the embed's space, so the page
does not shift when the placeholder is replaced.

With the default App Router layout, which passes the pending
`resolveConsent` result, the server HTML contains the placeholder, and the
browser swaps in the embed after it applies the resolved state if the
category is granted. No request reaches the embed's host before that.

With the
[awaited layout](/docs/frameworks/next/app-router#render-the-banner-in-the-server-html),
the server decides from the resolved rule and the request cookie whether the
category is allowed. Pages Router works the same way when `getServerSideProps`
awaits `resolveConsent` from `c15t/next/pages` and passes the result
through `state` to `ConsentRoot`, as in the
[Pages Router guide](../pages-router). A denied category gets the placeholder
in the server HTML. A granted category gets an empty wrapper, and the embed
mounts once hydration completes. With the awaited layout the page streams
inside a `Suspense` boundary, and React moves streamed HTML into place after
parsing it; an iframe that was already in that HTML would load twice.
Hydration keeps the server's decision as long as the browser's privacy
signals agree with the request: a `navigator.globalPrivacyControl`
that the request did not carry as `Sec-GPC` is re-detected on hydration and
can withdraw a category. Under an opt-in rule a new
visitor gets the placeholder and a returning visitor who allowed `marketing`
gets the iframe; under an opt-out rule a new visitor already has effective
permission, so the iframe renders until they opt out.

## Props

|Prop|Type|Default|Description|
|--|--|--|--|
|`category`|`AllConsentNames`|required|The consent category whose effective permission gates the children, for example `'marketing'` or `'functionality'`.|
|`children`|`ReactNode`|required|The embed. It is not mounted until the category is allowed, so it makes no requests before permission.|
|`placeholder`|`ReactNode`|built-in placeholder|Replaces the built-in title and button while the category is not allowed. Falsy values such as `null`, `false`, `0` and an empty string use the built-in placeholder. Pass an empty fragment, `<></>`, to render no placeholder content.|

Other `div` attributes such as `className`, `style` and `ref` apply to the
wrapper element that stays in the page in both states. `ConsentGateProps` also
declares `noStyle` and `theme`, but the component does not apply them yet;
style the wrapper with `className`, and style the built-in placeholder
through its parts in the provider options.

## Behavior

`ConsentGate` renders a `div` wrapper. Inside it, when the effective permission for
`category` is granted, it renders the children; otherwise it renders the
placeholder. Permission is the same value `useConsent(category)` returns,
so it can be granted under an opt-out rule before the visitor records a
choice, and a recorded grant can be overridden by a privacy signal.

While effective permission is denied, the children are absent from the DOM,
so an iframe or a third-party widget inside `ConsentGate` sends no requests. When the visitor later revokes it, the
children unmount and the embed disappears. `ConsentGate` does not depend on the
network blocker or the iframe blocker modules; use those for markup you
cannot wrap in a component.

The built-in placeholder shows the title `consentGate.title`,
`Accept {category} consent to view this content.`, with `{category}`
replaced by `consentTypes.<category>.title`, and a button labelled
`consentGate.actionButton`, `Enable {category} consent`. The button opens the
preference center; it
does not grant the category by itself, because the visitor still has to
save. Mount a `ConsentDialog` in the same provider so the button has
something to open.

Under a policy with `scopeMode: 'strict'`, a category outside the rule's
scope cannot be granted. The built-in placeholder then shows `consentGate.policyBlocked`,
"This content is unavailable under your region's consent policy.", without
the button, and a stored grant for that category stays blocked.

When the provider starts from a snapshot prefetched on the server, the
server renders the placeholder for a denied category. For a granted
category it renders the empty wrapper, and the children mount once
hydration completes, so an iframe loads once even when the page streams
inside a `Suspense` boundary. A grant in the request cookie allows the
embed only when policy and privacy signals permit it. Browser privacy signals detected during hydration can
withdraw that permission. In a browser-only setup the
provider has no permission until it resolves policy on the client, so
`ConsentGate` shows the placeholder first and swaps in the embed after resolution
when the category is already granted.

`ConsentGate` is one component on this page; the per-vendor embed guides for
[YouTube](/docs/integrations/youtube) and
[Google Maps](/docs/integrations/google-maps) show a complete embed
configuration with sizing, titles and the same `ConsentGate` usage across
frameworks.

To read the same permission in your own Client Components, use `useConsent`
from `c15t/next`; the `useIframeBlocker` and `useNetworkBlocker` module
hooks also import from there.

## Composition

The placeholder parts are available as `ConsentGate.Root`, `ConsentGate.Title` and
`ConsentGate.Button` for a custom placeholder that keeps the built-in copy and
behavior. `ConsentGate.Title` and `ConsentGate.Button` accept `category` and fill in the
translated text; pass children to either to replace it.

```tsx
<ConsentGate
  category="marketing"
  placeholder={
    <ConsentGate.Root>
      <ConsentGate.Title category="marketing" />
      <p>The video is also available on our channel.</p>
      <ConsentGate.Button category="marketing">Choose cookies</ConsentGate.Button>
    </ConsentGate.Root>
  }
>
  <iframe src="https://www.youtube-nocookie.com/embed/..." title="Product tour" />
</ConsentGate>
```

To restyle the built-in placeholder without replacing it, set its parts
under `components['consent-gate']` in the provider options. `root` is the
card, and `title` and `button` are its text and button. The `consentGate`, `consentGateTitle` and
`consentGateButton` theme slots reach the same parts. The button part
applies on top of `button.primary`.

```tsx
components: {
  'consent-gate': {
    root: { className: 'rounded-none' },
    button: { className: 'font-semibold' },
  },
},
```

The built-in placeholder carries `data-testid="consent-gate-placeholder"`,
its title `data-testid="consent-gate-title"` and its button
`data-testid="consent-gate-button"`.

## Accessibility

The placeholder is plain text and a `button`, so it is readable and
operable without the embed. Give the iframe a descriptive `title`, and keep a
transcript, address or link outside the `ConsentGate` for visitors who decline
the category. A denied category may be fixed by policy, so opening the
preference center does not guarantee that the visitor can grant it.

## Verify

Use an optional in-scope category that is available in the preference
center and is not restricted by policy or privacy signals such as GPC.
Load the page with the category denied. The placeholder text names the
category and the network panel shows no request to the embed's host. With a
prefetched snapshot, the server HTML contains the placeholder for a denied
category and neither placeholder nor embed for a granted category; the
embed appears after hydration and the network panel shows one request for
its document. Browser-only initialization
shows the placeholder before policy resolves, then replaces it with the
embed if the category is granted. After policy resolves, activate the
placeholder button: the preference center opens. Turn the category on and
Save: the placeholder is replaced by the embed and its requests start. Reject the
category again from the preference center: the embed disappears.
