Type something to search...
Embedding YouTube Videos in Sanity Portable Text

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 url type gives a URL input, and the custom() rule runs the same helper your front end uses, so anything Studio accepts will render.
  • title: an iframe needs a title attribute 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.

ApproachInitial costBest for
Plain iframe with loading="lazy"Low until scrolled into viewSingle videos lower on the page
Click-to-play facadeJust a thumbnail imageSeveral videos, or videos near the top
Thumbnail link to YouTubeJust a thumbnail imageEmail, 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 iframe title or the button label, which screen reader users rely on.
  • Check thumbnails: hqdefault.jpg is 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 videoEmbed type 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper