---
title: Embeds
description: Gate YouTube videos, maps, social posts and other iframes on an
  Astro site so they load only after the visitor allows their consent category,
  with a custom element or the c15t iframe blocker.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Why an embed needs gating

An iframe sends requests to its host as soon as it is in the page with a
`src`, before any script can stop it. `ConsentBanner` does not block iframes
you already have. Render an embed only while its category is allowed, and
remove it when the visitor withdraws permission.

Astro has no consent gate component. Use one of these:

|Approach|Use it when|
|--|--|
|A custom element that renders the iframe|You want a placeholder with a button in place of the embed|
|The iframe blocker, with `data-category` and `data-src`|You have iframe markup to gate as it is|

## Gate an embed with a custom element

This component shows a placeholder with a preferences button until
measurement is allowed. It adds the iframe once the visitor allows
measurement, and removes it when permission is withdrawn. It also keeps the
iframe out while the visitor has switched YouTube off in
[vendor consent](/docs/frameworks/astro/vendor-consent).

The component reads `client.isVendorAllowed('youtube')`, so declare `youtube`
in the `vendors` option of `c15t()` with `category: 'measurement'`. An
undeclared vendor reads as not allowed, and the video never loads:

```astro title="src/components/consent-video.astro"
---
import { ConsentDialogLink } from 'c15t/astro/components';
---

<consent-video>
	<div data-video>
		<p>Allow measurement to load this YouTube video.</p>
	</div>
	<ConsentDialogLink>Open privacy settings</ConsentDialogLink>
</consent-video>

<script>
	import { getConsentClient } from 'c15t/astro/client';

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

		connect = () => {
			const client = getConsentClient();
			const container = this.querySelector('[data-video]');
			if (this.dispose || !client || !container) {
				return;
			}
			const render = () => {
				// Measurement is allowed and the visitor has not switched YouTube
				// off. An undeclared vendor is never allowed, so declare youtube.
				if (!client.isVendorAllowed('youtube')) {
					container.textContent =
						'Allow measurement to load this YouTube video. No video request is sent before permission.';
					return;
				}
				if (container.querySelector('iframe')) {
					return;
				}
				const frame = document.createElement('iframe');
				frame.src = 'https://www.youtube-nocookie.com/embed/czTksCF6X8Y';
				frame.title = 'YouTube video';
				frame.allowFullscreen = true;
				container.replaceChildren(frame);
			};
			render();
			this.dispose = client.subscribe(render);
		};

		connectedCallback() {
			// c15t boots from a module script, which can run after this one.
			document.addEventListener('DOMContentLoaded', this.connect, {
				once: true,
			});
			this.connect();
		}

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

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

Use it like any Astro component. How it works:

* The iframe does not exist in the server HTML, so nothing loads before
  consent, even before the consent runtime starts.
* `client.subscribe(render)` renders again on every consent change.
* `connectedCallback` runs again when `ClientRouter` swaps in a page that
  contains the element, so the embed works across navigation.
* The first `connect()` can run before c15t has started, so the element tries
  again on `DOMContentLoaded`.

Change the category, the iframe `src` and the placeholder text for other
embeds. The [YouTube](/docs/integrations/youtube) and
[Google Maps](/docs/integrations/google-maps) guides use the same pattern with
a reusable browser helper. [Integrations](/docs/integrations/overview) lists
the other vendors.

## Gate existing iframe markup

The consent runtime includes an iframe blocker, on by default. Mark an iframe
with a category and move its URL from `src` to `data-src`:

```html title="src/pages/contact.astro (partial)"
<iframe
  data-category="marketing"
  data-src="https://www.google.com/maps/embed?pb=..."
  title="Office location"
></iframe>
```

When the category is allowed, the blocker copies `data-src` to `src`. When it
is withdrawn, the blocker removes `src` again. `data-vendor` gates the iframe
on one vendor as well. See [vendor consent](/docs/frameworks/astro/vendor-consent).

Always use `data-src`, never `src`, for a gated iframe. The browser starts
loading a `src` from the HTML before the blocker runs, so an iframe with `src`
in the markup loads before consent.

The blocker watches the whole document, so it also gates iframes on pages you
reach with `ClientRouter`, which replaces `<body>` on each navigation.

## Gate an embed on the server

On a server-rendered page, `Astro.locals.c15t.snapshot.effectivePermissions`
tells you whether the visitor had allowed the category when the request
arrived. Rendering the iframe on the server from it works for returning
visitors. A visitor who allows the category on the page sees the embed only
after the next navigation, so pair it with the custom element, or use the
custom element alone. See [Server API](/docs/frameworks/astro/server).

## Check the embeds

1. Open the page in a private window with DevTools Network open. There is no
   request to the embed's host, and no iframe from it in the Elements panel.
2. Allow the embed's category from **Privacy settings**. The iframe appears
   and loads.
3. Reload. The iframe loads again without a new choice.
4. Withdraw the category and save. The page reloads, and the embed's host
   gets no request.

See [Verify consent](/docs/guides/verify-consent) for the full checklist.
