Embed Basic
A responsive, accessible sandboxed iframe block for approved HTTPS embeds — installed as a wired Payload block.
import type { Block } from 'payload'import { validateEmbedUrl } from '@/blocks/shared/safeUrls'export const EmbedBasic: Block = { slug: 'embedBasic', interfaceName: 'EmbedBasicBlock', fields: [ { name: 'url', type: 'text', required: true, validate: validateEmbedUrl, admin: { description: 'Approved HTTPS embed URL (e.g. https://www.youtube.com/embed/VIDEO_ID).', }, }, { name: 'title', type: 'text', required: true, admin: { description: 'Accessible title announced to screen readers for the embedded frame.', }, }, { name: 'aspectRatio', type: 'select', required: true, defaultValue: '16:9', options: [ { label: '16:9 (widescreen video)', value: '16:9' }, { label: '4:3 (standard video)', value: '4:3' }, { label: '1:1 (square)', value: '1:1' }, { label: '21:9 (ultrawide)', value: '21:9' }, ], }, { name: 'caption', type: 'text', }, { name: 'allowFullscreen', type: 'checkbox', defaultValue: true, }, ], labels: { plural: 'Embed Basic Blocks', singular: 'Embed Basic', },}import React from 'react'import type { EmbedBasicBlock as EmbedBasicBlockData } from '@/payload-types'import { getSafeEmbedUrl } from '@/blocks/shared/safeUrls'import { cn } from '@/utilities/ui'type Props = EmbedBasicBlockData & { id?: string className?: string}const aspectClassByRatio: Record<NonNullable<EmbedBasicBlockData['aspectRatio']>, string> = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '1:1': 'aspect-square', '21:9': 'aspect-[21/9]',}export const EmbedBasicBlock: React.FC<Props> = ({ allowFullscreen, aspectRatio, caption, className, id, title, url,}) => { const aspectClass = aspectClassByRatio[aspectRatio ?? '16:9'] ?? aspectClassByRatio['16:9'] const safeUrl = getSafeEmbedUrl(url) return ( <section className={cn('container', className)} id={id ? `block-${id}` : undefined}> <figure className="overflow-hidden rounded-frame border border-border/70 bg-card/35"> <div className={cn('relative w-full bg-muted', aspectClass)}> {safeUrl ? ( <iframe allow="encrypted-media; picture-in-picture" allowFullScreen={allowFullscreen ?? true} className="absolute inset-0 h-full w-full border-0" loading="lazy" referrerPolicy="strict-origin-when-cross-origin" sandbox="allow-forms allow-popups allow-presentation allow-scripts" src={safeUrl} title={title} /> ) : null} </div> {caption ? ( <figcaption className="px-6 py-4 text-sm text-muted-foreground">{caption}</figcaption> ) : null} </figure> </section> )}const approvedEmbedHosts = new Set([ 'airtable.com', 'docs.google.com', 'form.typeform.com', 'lookerstudio.google.com', 'maps.google.com', 'player.vimeo.com', 'vimeo.com', 'www.airtable.com', 'www.google.com', 'www.youtube-nocookie.com', 'www.youtube.com', 'youtube-nocookie.com', 'youtube.com',])const approvedEmbedHostSuffixes = [ '.airtable.com', '.google.com', '.typeform.com', '.vimeo.com', '.youtube-nocookie.com', '.youtube.com',]const embedUrlError = 'Use an approved HTTPS embed URL.'const formActionError = 'Use a same-origin path, such as /api/newsletter.'const sameOriginBase = 'https://payload-components.local'const isApprovedEmbedHost = (hostname: string) => { const normalizedHost = hostname.toLowerCase() return ( approvedEmbedHosts.has(normalizedHost) || approvedEmbedHostSuffixes.some((suffix) => normalizedHost.endsWith(suffix)) )}export const getSafeEmbedUrl = (value: unknown) => { if (typeof value !== 'string') return undefined const trimmed = value.trim() if (!trimmed) return undefined try { const parsed = new URL(trimmed) if (parsed.protocol !== 'https:' || parsed.username || parsed.password) { return undefined } if (!isApprovedEmbedHost(parsed.hostname)) { return undefined } return parsed.toString() } catch { return undefined }}export const validateEmbedUrl = (value: unknown) => getSafeEmbedUrl(value) ? true : embedUrlErrorexport const getSafeFormAction = (value: unknown) => { if (typeof value !== 'string') return undefined const trimmed = value.trim() if (!trimmed || !trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.includes('\\')) { return undefined } try { const parsed = new URL(trimmed, sameOriginBase) if (parsed.origin !== sameOriginBase) { return undefined } return `${parsed.pathname}${parsed.search}${parsed.hash}` } catch { return undefined }}export const validateSameOriginFormAction = (value: unknown) => { if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) { return true } return getSafeFormAction(value) ? true : formActionError}Installation
npx payload-components add embed-basicCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/embed-basic.jsonWhat it installs
Copies 3 source files into your project:
src/blocks/EmbedBasic/config.tssrc/blocks/EmbedBasic/Component.tsxsrc/blocks/shared/safeUrls.tsshared
…and makes 4 edits to wire the block into your project:
| Registers the block | src/collections/Pages/index.ts |
| Maps the renderer | src/blocks/RenderBlocks.tsx |
| Regenerates types | src/payload-types.ts |
| Regenerates the admin import map | src/app/(payload)/admin/importMap.js |
safeUrls.ts is the shared field core for this family — every variant composes it. Editing it updates each installed block at once, and re-running an install never overwrites a copy you have changed.Re-running the install converges: it detects existing wiring, skips it, and records install state in .payload-components/state.json.
Content model
The block accepts approved HTTPS provider embed URLs — paste the provider's embed URL (e.g.
https://www.youtube.com/embed/VIDEO_ID, not the watch URL). The iframe renders with a sandbox
and a narrowed permissions policy.
Prop
Type
Usage
EmbedBasic block to its layout.RenderBlocks on the frontend, fully typed — no extra wiring.Requirements
- Target
- payload-website-starter
- Payload
- v3
- Next.js
- 15 / 16
- shadcn UI
- none
Your project must already expose components.json, src/payload.config.ts, src/blocks/RenderBlocks.tsx, src/collections/Pages/index.ts — the surfaces payload-components add patches. The CLI verifies this against the support matrix before touching anything.