---
title: YouTube
description: Gate YouTube embeds with c15t v3 in Next.js, TanStack Start, React,
  Nuxt, Vue, Astro, Svelte, SvelteKit or JavaScript.
icon: youtube
group: integrations
lastModified: "2026-10-10T16:01:45+01:00"
---
## Configure the video

Use the ID of an embeddable video and the category matching its purpose in your
policy. This page uses `marketing` because the YouTube player can set
advertising identifiers. The framework quickstarts gate their demo video on
`measurement` instead; either works if your policy says so. The current v3 adapters use consent-gated iframes rather than the v2
`YouTubeEmbed` convenience component.

```ts title="src/embed-config.ts"
export const embedCategory = 'marketing' as const;
export const embedURL =
	'https://www.youtube-nocookie.com/embed/VIDEO_ID?playsinline=1';
export const embedTitle = 'Product walkthrough';
export const embedAspectRatio = '16 / 9';
```

Replace `VIDEO_ID` before running the example.

## Add the embed to your framework

Create `src/embed-config.ts` using the configuration on this page, then select
your framework. Keep the existing [consent setup](/docs/frameworks), its policy
and preferences UI. These examples do not create a second consent provider.

**Next.js**

Render this component inside your existing consent boundary or provider.

```tsx title="src/consent-embed.tsx"
'use client';

import { ConsentGate } from 'c15t/next';
import { embedCategory, embedURL, embedTitle, embedAspectRatio } from './embed-config';

export function ConsentEmbed() {
  return (
    <ConsentGate category={embedCategory}>
      <iframe
        src={embedURL}
        title={embedTitle}
        loading="lazy"
        allowFullScreen
        style={{ width: '100%', aspectRatio: embedAspectRatio, minHeight: 200, border: 0 }}
      />
    </ConsentGate>
  );
}
```

`ConsentGate` keeps the iframe absent while permission is denied and removes it
on revocation. Keep your existing consent styles and preferences dialog.

**TanStack Start**

Render this component inside your existing consent boundary or provider.

```tsx title="src/consent-embed.tsx"
import { ConsentGate } from 'c15t/tanstack-start';
import { embedCategory, embedURL, embedTitle, embedAspectRatio } from './embed-config';

export function ConsentEmbed() {
  return (
    <ConsentGate category={embedCategory}>
      <iframe
        src={embedURL}
        title={embedTitle}
        loading="lazy"
        allowFullScreen
        style={{ width: '100%', aspectRatio: embedAspectRatio, minHeight: 200, border: 0 }}
      />
    </ConsentGate>
  );
}
```

`ConsentGate` keeps the iframe absent while permission is denied and removes it
on revocation. Keep your existing consent styles and preferences dialog.

**React**

Render this component inside your existing consent boundary or provider.

```tsx title="src/consent-embed.tsx"
import { ConsentGate } from 'c15t/react';
import { embedCategory, embedURL, embedTitle, embedAspectRatio } from './embed-config';

export function ConsentEmbed() {
  return (
    <ConsentGate category={embedCategory}>
      <iframe
        src={embedURL}
        title={embedTitle}
        loading="lazy"
        allowFullScreen
        style={{ width: '100%', aspectRatio: embedAspectRatio, minHeight: 200, border: 0 }}
      />
    </ConsentGate>
  );
}
```

`ConsentGate` keeps the iframe absent while permission is denied and removes it
on revocation. Keep your existing consent styles and preferences dialog.

**Nuxt**

The Nuxt module registers `ConsentGate` and `ConsentDialogLink`, so
this component imports only the embed configuration.

```vue title="app/components/ConsentEmbed.vue"
<script setup lang="ts">
import { embedCategory, embedURL, embedTitle, embedAspectRatio } from '../../src/embed-config';
</script>

<template>
  <ConsentGate :category="embedCategory">
    <iframe
      :src="embedURL"
      :title="embedTitle"
      loading="lazy"
      allowfullscreen
      :style="{ width: '100%', aspectRatio: embedAspectRatio, minHeight: '200px', border: 0 }"
    />
    <template #placeholder>
      <p>Allow {{ embedCategory }} to load this content.</p>
      <ConsentDialogLink>Open privacy settings</ConsentDialogLink>
    </template>
  </ConsentGate>
</template>
```

`ConsentGate` keeps the iframe out of the server HTML and the DOM while
permission is denied, and removes it on revocation. See
[Nuxt scripts and embeds](/docs/frameworks/nuxt/scripts).

**Vue**

Render this component inside the app that installed the c15t Vue plugin.

```vue title="src/ConsentEmbed.vue"
<script setup lang="ts">
import { ConsentDialogLink, ConsentGate } from 'c15t/vue/vue-plugin';
import { embedCategory, embedURL, embedTitle, embedAspectRatio } from './embed-config';
</script>

<template>
  <ConsentGate :category="embedCategory">
    <iframe
      :src="embedURL"
      :title="embedTitle"
      loading="lazy"
      allowfullscreen
      :style="{ width: '100%', aspectRatio: embedAspectRatio, minHeight: '200px', border: 0 }"
    />
    <template #placeholder>
      <p>Allow {{ embedCategory }} to load this content.</p>
      <ConsentDialogLink>Open privacy settings</ConsentDialogLink>
    </template>
  </ConsentGate>
</template>
```

`ConsentGate` keeps the iframe out of the DOM while permission is denied,
and removes it on revocation. See
[Vue scripts and embeds](/docs/frameworks/vue/scripts).

**Astro**

Use the shared browser helper below with Astro's existing page runtime.
Add this component to pages using your consent-enabled base layout:

```astro title="src/components/ConsentEmbed.astro"
<c15t-consent-embed style="display: block"></c15t-consent-embed>

<script>
  import { getConsentClient } from 'c15t/astro/client';
  import { mountConsentEmbed } from '../consent-embed';

  class ConsentEmbed extends HTMLElement {
    dispose?: () => void;

    connect = () => {
      if (this.dispose) return;
      const client = getConsentClient();
      if (!client) return;
      this.dispose = mountConsentEmbed(
        this,
        client.runtime.kernel,
        () => { void client.openDialog(); },
      );
    };

    connectedCallback() {
      document.addEventListener('DOMContentLoaded', this.connect, { once: true });
      this.connect();
    }

    disconnectedCallback() {
      document.removeEventListener('DOMContentLoaded', this.connect);
      this.dispose?.();
      this.dispose = undefined;
    }
  }

  if (!customElements.get('c15t-consent-embed')) {
    customElements.define('c15t-consent-embed', ConsentEmbed);
  }
</script>
```

The first mount waits for the page's module scripts, including c15t's boot
script. Later `ClientRouter` mounts reuse the existing runtime. Removing the
component unsubscribes and removes the iframe. Do not create another consent
runtime for this embed.

**Svelte**

Render this component inside the existing `ConsentProvider`.
The provider from your quickstart supplies its consent state.

```svelte title="src/ConsentEmbed.svelte"
<script lang="ts">
  import { ConsentGate } from '@c15t/svelte';
  import { embedCategory, embedURL, embedTitle, embedAspectRatio } from './embed-config';
</script>

<ConsentGate category={embedCategory}>
  <iframe
    src={embedURL}
    title={embedTitle}
    loading="lazy"
    allowfullscreen
    style:width="100%"
    style:aspect-ratio={embedAspectRatio}
    style:min-height="200px"
    style:border="0"
  ></iframe>
</ConsentGate>
```

The Svelte `ConsentGate` waits until the browser is mounted and the category is
allowed. Its default placeholder opens preferences. Revocation removes the
iframe.

**SvelteKit**

Render this component inside the existing `ConsentRoot`.
Keep the SvelteKit root and its server-resolved state unchanged.

```svelte title="src/lib/ConsentEmbed.svelte"
<script lang="ts">
  import { ConsentGate } from '@c15t/svelte';
  import { embedCategory, embedURL, embedTitle, embedAspectRatio } from '../embed-config';
</script>

<ConsentGate category={embedCategory}>
  <iframe
    src={embedURL}
    title={embedTitle}
    loading="lazy"
    allowfullscreen
    style:width="100%"
    style:aspect-ratio={embedAspectRatio}
    style:min-height="200px"
    style:border="0"
  ></iframe>
</ConsentGate>
```

The Svelte `ConsentGate` waits until the browser is mounted and the category is
allowed. Its default placeholder opens preferences. Revocation removes the
iframe.

**HTML**

The c15t script tag gates iframes that name a category. Put the embed's URL
in `data-src` instead of `src`, using the values from the configuration on
this page:

```html
<iframe
  data-src="https://www.youtube-nocookie.com/embed/VIDEO_ID?playsinline=1"
  data-category="measurement"
  title="Product video"
  loading="lazy"
  allowfullscreen
></iframe>
```

c15t sets `src` once the category is allowed and removes it on revocation.
Without `src` the iframe loads nothing, so hide it with CSS and show a link
to `#c15t-preferences` in its place. See
[HTML embeds](/docs/frameworks/html/embeds).

**JavaScript**

Use the shared browser helper below with your existing client. Put an
empty container where the embed should appear:

```html
<div id="consent-embed"></div>
```

In your browser entry point, after `init()`:

```ts
import { mountConsentEmbed } from './consent-embed';

const container = document.querySelector<HTMLElement>('#consent-embed');
if (!container) throw new Error('Missing consent embed container');

const disposeEmbed = mountConsentEmbed(container, consent.kernel, () =>
  consent.openDialog(),
);
```

`consent` is the client from `init()` in your quickstart. With
`createConsentRuntime`, pass `runtime.kernel` and your own function that
opens preferences. Call `disposeEmbed()` when the page or component is
destroyed. Iframe markup with `data-src` and `data-category` also works
without the helper, because both setups gate iframes by default.

## Browser helper for Astro and JavaScript

Only the Astro and JavaScript examples need this helper. It creates the iframe
when permission allows it, keeps an existing player mounted across unrelated
snapshot updates, and removes it on revocation.

```ts title="src/consent-embed.ts"
import {
	embedCategory,
	embedURL,
	embedTitle,
	embedAspectRatio,
} from './embed-config';

type EmbedKernel = {
	getSnapshot: () => {
		effectivePermissions: Record<typeof embedCategory, boolean>;
	};
	subscribe: (listener: () => void) => () => void;
};

export function mountConsentEmbed(
	container: HTMLElement,
	kernel: EmbedKernel,
	openPreferences: () => void
) {
	const render = () => {
		if (kernel.getSnapshot().effectivePermissions[embedCategory]) {
			if (container.querySelector('iframe')) return;
			const frame = document.createElement('iframe');
			frame.src = embedURL;
			frame.title = embedTitle;
			frame.loading = 'lazy';
			frame.allowFullscreen = true;
			Object.assign(frame.style, {
				width: '100%',
				aspectRatio: embedAspectRatio,
				minHeight: '200px',
				border: '0',
			});
			container.replaceChildren(frame);
		} else {
			if (container.querySelector('button')) return;
			const button = document.createElement('button');
			button.type = 'button';
			button.textContent = 'Open privacy settings to view this content';
			button.onclick = openPreferences;
			container.replaceChildren(button);
		}
	};

	render();
	const unsubscribe = kernel.subscribe(render);
	return () => {
		unsubscribe();
		container.replaceChildren();
	};
}
```

Keep a transcript, address or other useful alternative outside the embed.
`loading="lazy"` is a performance hint; the consent condition controls whether
the iframe exists at all. A denied category may be fixed by policy, so opening
preferences does not guarantee that the visitor can grant it.

## Gate existing iframe markup

For markup that uses `data-category` and `data-src`, the
`createIframeBlocker` module from `c15t/modules/iframe-blocker` promotes
`data-src` only when the category has consent and the URL resolves to HTTP or
HTTPS. Relative URLs resolve against the iframe document's base URL.
Malformed URLs and other schemes, including `javascript:`, `data:` and `blob:`,
remain in `data-src` without loading. Correct the URL and call
`processAllIframes()` to retry.

Keep the initial URL in `data-src`, without `src`, so the browser cannot load
it before the blocker runs. The module does not sanitize arbitrary HTML or
modify iframes without `data-category`.

## Customize the player and placeholder

Set playback options in the iframe URL. For example, append `start=36` to begin
36 seconds into the video. Use supported
[YouTube player parameters](https://developers.google.com/youtube/player_parameters)
and reserve at least a 200-by-200-pixel player area.

React and Svelte `ConsentGate` components provide a blocked-state placeholder and
consent action. Their `placeholder` APIs differ. React takes a node, and
Svelte takes a snippet. Keep a clear way to reopen preferences when customizing them.
The Vue and browser examples show that action explicitly. `loading="lazy"` delays an
allowed iframe for performance; it does not implement consent gating.

## Consent behavior

Add `data-vendor="youtube"` next to `data-category` and declare a `youtube`
vendor in the runtime's `vendors` option or the backend manifest to let visitors
turn YouTube off while keeping the rest of the category on. See
[vendor consent for your framework](/docs/integrations/overview#vendor-switches-and-cookie-cleanup).

Each example keeps the iframe absent until the category is allowed. After
permission, the iframe can load. Revoking permission removes the iframe and its
embedded document. Requests already sent cannot be recalled.

The `youtube-nocookie.com` hostname does not replace the consent boundary. Avoid
loading a remote thumbnail, preconnecting to YouTube, or installing the IFrame
Player API separately if you require no YouTube request before permission.
This recipe embeds a video; it does not load the JavaScript Player API.

## Verify the integration

In a fresh opt-in session, confirm no iframe or YouTube request exists. Grant
marketing permission and play the video, then revoke it and confirm the iframe
is removed. Test a narrow viewport and keyboard access to the placeholder and
player. See [consent verification](/docs/guides/verify-consent).
