
Embedding YouTube Videos in Sanity Portable Text
To embed YouTube videos in Sanity Portable Text, create a youtube object type with a url field, add it to the of array of your Portable Text field, and render it on the front end with a component that extracts the video ID and outputs a responsive iframe. Editors paste a normal YouTube link, Sanity stores it as a structured block between paragraphs, and your renderer decides how the player looks and loads. Adding URL validation, a thumbnail preview in Studio and a lightweight click-to-play embed makes the setup reliable for editors and fast for visitors.
Pasting embed code into a rich text field is how many CMSs handle video, and it leads to inconsistent sizes, tracking scripts you didn't approve and broken markup. With Portable Text, the video is data: just a URL and a few options. This guide walks through the schema, validation, a Studio preview, the GROQ query, a responsive React embed, a performance-friendly facade, and options such as start times and captions.
Why a Custom Block Instead of Embed Code
Storing a URL rather than an iframe snippet gives you several advantages:
- Consistent output: every video uses the same responsive player, so you change the design in one component.
- Privacy control: you can switch every embed to YouTube's privacy-enhanced domain without touching content.
- Performance control: you can load a thumbnail first and only load the player when someone clicks.
- Validation: Studio can reject links that aren't YouTube videos before they're published.
- Portability: a mobile app or email template can render the same block differently, for example as a thumbnail link.
If you haven't added a custom block before, adding custom blocks to Sanity Portable Text explains the general pattern this article builds on.
Step 1: Write a Shared Video ID Helper
YouTube links come in several shapes: youtube.com/watch?v=, youtu.be/, youtube.com/embed/ and youtube.com/shorts/. A small helper that handles all of them is useful in both Studio and your front end:
// src/lib/youtube.ts
const ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/;
export function getYouTubeId(input?: string | null): string | null {
if (!input) return null;
let url: URL;
try {
url = new URL(input.trim());
} catch {
return null;
}
const host = url.hostname.replace(/^www\.|^m\./, '');
let id: string | null = null;
if (host === 'youtu.be') {
id = url.pathname.slice(1);
} else if (host === 'youtube.com' || host === 'youtube-nocookie.com') {
if (url.pathname === '/watch') {
id = url.searchParams.get('v');
} else {
const [, kind, value] = url.pathname.split('/');
if (['embed', 'shorts', 'live'].includes(kind)) id = value ?? null;
}
}
return id && ID_PATTERN.test(id) ? id : null;
}
Using the built-in URL parser is more robust than a single regular expression, and the final check on the 11-character ID format rejects anything unexpected. If your Studio lives in a separate repository from your front end, copy the helper into both or publish it as a small shared package.
Step 2: Define the YouTube Block Type
// schemaTypes/objects/youtube.ts
import { defineField, defineType } from 'sanity';
import { PlayIcon } from '@sanity/icons';
import { getYouTubeId } from '../../src/lib/youtube';
import { YouTubePreview } from '../components/YouTubePreview';
export const youtube = defineType({
name: 'youtube',
title: 'YouTube video',
type: 'object',
icon: PlayIcon,
fields: [
defineField({
name: 'url',
title: 'YouTube URL',
type: 'url',
description: 'Paste a link such as https://www.youtube.com/watch?v=…',
validation: (rule) =>
rule.required().custom((value) =>
!value || getYouTubeId(value) ? true : 'This does not look like a YouTube video URL',
),
}),
defineField({
name: 'title',
title: 'Video title',
type: 'string',
description: 'Used as the accessible name of the player.',
validation: (rule) => rule.required(),
}),
defineField({
name: 'startAt',
title: 'Start at (seconds)',
type: 'number',
validation: (rule) => rule.integer().min(0),
}),
defineField({
name: 'caption',
title: 'Caption',
type: 'string',
}),
],
preview: {
select: { title: 'title', url: 'url' },
},
components: {
preview: YouTubePreview,
},
});
Adjust the import path for the helper to match your project. A few notes on the fields:
- url: the
urltype gives a URL input, and thecustom()rule runs the same helper your front end uses, so anything Studio accepts will render. - title: an
iframeneeds atitleattribute for screen readers. Making it required avoids silent accessibility gaps. - startAt: lets editors start a video partway through, which is handy for long talks.
- caption: optional text shown under the video.
More on custom rules is in how to add validation rules to Sanity fields.
Step 3: Add a Thumbnail Preview in Studio
Without a custom preview, the block shows only its title in the editor. A thumbnail makes it obvious which video is which. YouTube serves thumbnails at a predictable URL, so you don't need the full player inside Studio:
// schemaTypes/components/YouTubePreview.tsx
import type { PreviewProps } from 'sanity';
import { Box, Card, Flex, Text } from '@sanity/ui';
import { getYouTubeId } from '../../src/lib/youtube';
type Props = PreviewProps & { url?: string };
export function YouTubePreview(props: Props) {
const id = getYouTubeId(props.url);
return (
<Card padding={2}>
<Flex gap={3} align="center">
{id ? (
<img
src={`https://i.ytimg.com/vi/${id}/mqdefault.jpg`}
alt=""
width={160}
height={90}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<Box flex={1}>
<Text size={1} weight="semibold">
{(props.title as string) || 'YouTube video'}
</Text>
<Box marginTop={2}>
<Text size={1} muted>
{id ? props.url : 'Add a valid YouTube URL'}
</Text>
</Box>
</Box>
</Flex>
</Card>
);
}
The values listed in preview.select arrive as props, which is why url is available alongside title. @sanity/ui is already part of Studio, so these components match the rest of the interface.
Step 4: Add the Block to Your Portable Text Field
Register the type and include it in your rich text array:
// schemaTypes/blockContent.ts
import { defineArrayMember, defineType } from 'sanity';
export const blockContent = defineType({
name: 'blockContent',
title: 'Block Content',
type: 'array',
of: [
defineArrayMember({ type: 'block' }),
defineArrayMember({ type: 'image', options: { hotspot: true } }),
defineArrayMember({ type: 'youtube' }),
],
});
// schemaTypes/index.ts
import { blockContent } from './blockContent';
import { post } from './post';
import { youtube } from './objects/youtube';
export const schemaTypes = [post, blockContent, youtube];
Editors now see YouTube video in the editor's insert menu. Stored data is compact:
{
"_type": "youtube",
"_key": "v81c",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Project walkthrough",
"startAt": 30,
"caption": "A five-minute tour of the project."
}
Step 5: Query the Content
Because the block contains only plain fields, a standard query returns everything:
*[_type == "post" && slug.current == $slug][0]{
title,
body
}
If you also want a list of every video in a post, for example to add structured data or a "videos in this article" section, filter the array:
*[_type == "post" && slug.current == $slug][0]{
title,
"videos": body[_type == "youtube"]{ url, title }
}
And to find posts that embed any video:
*[_type == "post" && count(body[_type == "youtube"]) > 0]{ title }
Array filtering is covered in how to query nested arrays in GROQ.
Step 6: Render a Responsive Embed in React
The simplest renderer outputs an iframe with a 16:9 aspect ratio. Using youtube-nocookie.com, YouTube's privacy-enhanced mode, avoids setting tracking cookies until a visitor plays the video:
// src/components/YouTubeEmbed.tsx
import { getYouTubeId } from '@/lib/youtube';
type Props = {
url?: string;
title?: string;
startAt?: number;
caption?: string;
};
export function YouTubeEmbed({ url, title, startAt, caption }: Props) {
const id = getYouTubeId(url);
if (!id) return null;
const params = new URLSearchParams({ rel: '0' });
if (startAt) params.set('start', String(startAt));
return (
<figure className="my-8">
<div style={{ position: 'relative', aspectRatio: '16 / 9' }}>
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}?${params}`}
title={title ?? 'YouTube video'}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
/>
</div>
{caption && <figcaption className="mt-2 text-sm">{caption}</figcaption>}
</figure>
);
}
Wire it into your Portable Text components under types, using the block's _type as the key:
// src/components/portable-text-components.tsx
import type { PortableTextComponents } from '@portabletext/react';
import { YouTubeEmbed } from './YouTubeEmbed';
export const components: PortableTextComponents = {
types: {
youtube: ({ value }) => <YouTubeEmbed {...value} />,
},
};
loading="lazy" defers loading the player until it's near the viewport. Returning null for invalid URLs means a half-finished draft never breaks the page. For the rest of the renderer setup, see how to render Portable Text in React.
Step 7: Load the Player Only on Click
Even lazily loaded, a YouTube iframe pulls in a substantial amount of JavaScript. If a page has several videos, or the video sits high on the page, a facade is faster: show the thumbnail and a play button, and swap in the real player when the visitor clicks.
// src/components/YouTubeFacade.tsx
'use client';
import { useState } from 'react';
import { getYouTubeId } from '@/lib/youtube';
type Props = { url?: string; title?: string; startAt?: number };
export function YouTubeFacade({ url, title, startAt }: Props) {
const [active, setActive] = useState(false);
const id = getYouTubeId(url);
if (!id) return null;
const params = new URLSearchParams({ autoplay: '1', rel: '0' });
if (startAt) params.set('start', String(startAt));
return (
<div style={{ position: 'relative', aspectRatio: '16 / 9' }}>
{active ? (
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}?${params}`}
title={title ?? 'YouTube video'}
allow="autoplay; encrypted-media; picture-in-picture; web-share"
allowFullScreen
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
/>
) : (
<button
type="button"
onClick={() => setActive(true)}
aria-label={`Play video: ${title ?? 'YouTube video'}`}
style={{ position: 'absolute', inset: 0, padding: 0, border: 0, cursor: 'pointer' }}
>
<img
src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</button>
)}
</div>
);
}
This is a client component because it uses state, but your Portable Text renderer can still run as a Server Component and render it inside the youtube type. The autoplay=1 parameter starts playback after the click, so visitors don't have to click twice. If you'd rather not build your own, community web components such as lite-youtube-embed implement the same idea.
| Approach | Initial cost | Best for |
|---|---|---|
Plain iframe with loading="lazy" | Low until scrolled into view | Single videos lower on the page |
| Click-to-play facade | Just a thumbnail image | Several videos, or videos near the top |
| Thumbnail link to YouTube | Just a thumbnail image | Email, RSS and AMP-style outputs |
Tips for Video Embeds
- Keep editors in mind: a clear field description with an example URL prevents most mistakes.
- Always set a title: it becomes the
iframetitle or the button label, which screen reader users rely on. - Check thumbnails:
hqdefault.jpgis reliably available for public videos, while higher-resolution variants aren't always available. - Reuse the pattern: Vimeo and other providers work the same way, with their own ID helper and embed URL. Consider a single
videoEmbedtype with a provider field if you support several.
FAQ: YouTube Embeds in Portable Text
Not directly, and you shouldn't need to. Create a youtube object type with a URL field, add it to your Portable Text array, and let your front-end component generate the embed from the stored URL.
Use a custom validation rule that calls the same ID helper as your front end. If the helper can't extract an 11-character video ID, return an error message so Studio blocks publishing.
Select the url field in the type's preview, then set components.preview to a React component that builds a thumbnail URL from the video ID, such as https://i.ytimg.com/vi/ID/mqdefault.jpg.
Wrap the iframe in a container with aspect-ratio set to 16 / 9 and position the iframe absolutely to fill it. The video then scales with the width of its container.
It's YouTube's privacy-enhanced embed domain. Using it in your embed URL means YouTube doesn't set tracking cookies for visitors until they play the video.
Use loading="lazy" on the iframe at a minimum. For the biggest gain, show a thumbnail with a play button and only render the iframe after the visitor clicks.
Conclusion
Embedding YouTube videos in Sanity Portable Text works best as a dedicated block type. Store the URL, a title and optional settings in a youtube object, validate the URL with a shared ID helper, and give editors a thumbnail preview so they can see what they've added. The content stays clean, portable data rather than pasted embed code.
On the front end, map the youtube type to a responsive embed using the privacy-enhanced domain, and switch to a click-to-play facade when performance matters. Because every video flows through one component, you can change the player, add consent handling or support new providers later without editing a single post.


