TL;DR
We'll build two small Node.js modules: one renders
VideoObjectJSON-LD from an asset record, one renders a video sitemap. Then a test fails the build when required fields are missing. Node 22, no dependencies.
What we're building 🧱
Your video backend, whichever API you use, gives you per-asset data: title, duration, thumbnail, created date, a playback URL. We'll map that to the markup Google documents. Per Google's video structured data page, name, thumbnailUrl and uploadDate are required; description, duration, contentUrl, embedUrl and others are recommended.
1. The asset shape
// asset.js export const sample = { id: "vid_123", title: "Segment length tradeoffs in HLS", description: "Why six seconds is not always right.", durationSeconds: 750, createdAt: "2026-10-12T08:00:00Z", thumbnails: ["https://example.com/t/1x1.jpg", "https://example.com/t/4x3.jpg", "https://example.com/t/16x9.jpg"], mp4Url: "https://example.com/v/vid_123.mp4", embedUrl: "https://example.com/embed/vid_123", pageUrl: "https://example.com/watch/vid_123", }; 2. Seconds to ISO 8601 duration
// iso-duration.js export function isoDuration(totalSeconds) { const s = Math.round(totalSeconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return "PT" + (h ? `${h}H` : "") + (m ? `${m}M` : "") + (sec || (!h && !m) ? `${sec}S` : ""); } $ node -e "import('./iso-duration.js').then(m=>console.log(m.isoDuration(114), m.isoDuration(750), m.isoDuration(3600)))" PT1M54S PT12M30S PT1H PT1M54S matches the format in Google's example.
3. Render the JSON-LD
// jsonld.js import { isoDuration } from "./iso-duration.js"; export function videoObject(a) { return { "@context": "https://schema.org", "@type": "VideoObject", name: a.title, description: a.description, thumbnailUrl: a.thumbnails, uploadDate: a.createdAt, duration: isoDuration(a.durationSeconds), contentUrl: a.mp4Url, embedUrl: a.embedUrl, }; } export function scriptTag(a) { const json = JSON.stringify(videoObject(a)).replace(/</g, "\\u003c"); return `<script type="application/ld+json">${json}</script>`; } ⚠️ Note: Escaping
<stops a title containing</script>from breaking out of the tag.
4. Render the video sitemap
Per Google's sitemap docs, each video needs a thumbnail location, title, description (max 2,048 characters) and either content_loc or player_loc. content_loc must not equal the parent <loc>, and streaming protocols are not supported, so we use the MP4, not the .m3u8.
// sitemap.js const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); export function videoSitemap(assets) { const items = assets.map((a) => ` <url> <loc>${esc(a.pageUrl)}</loc> <video:video> <video:thumbnail_loc>${esc(a.thumbnails.at(-1))}</video:thumbnail_loc> <video:title>${esc(a.title)}</video:title> <video:description>${esc(a.description.slice(0, 2048))}</video:description> <video:content_loc>${esc(a.mp4Url)}</video:content_loc> <video:duration>${Math.round(a.durationSeconds)}</video:duration> </video:video> </url>`).join("\n"); return `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"> ${items} </urlset> `; } The video:duration range per the docs is 1 to 28,800 seconds, so clamp live or empty assets out.
5. A test that fails the build
// jsonld.test.js import test from "node:test"; import assert from "node:assert/strict"; import { videoObject } from "./jsonld.js"; import { sample } from "./asset.js"; test("required VideoObject fields present", () => { const v = videoObject(sample); for (const k of ["name", "thumbnailUrl", "uploadDate"]) { assert.ok(v[k] && v[k].length !== 0, `missing ${k}`); } }); test("duration is ISO 8601", () => { assert.match(videoObject(sample).duration, /^PT(\d+H)?(\d+M)?(\d+S)?$/); }); $ node --test ✔ required VideoObject fields present ✔ duration is ISO 8601 6. Pitfalls ⚠️
| Pitfall | Why |
|---|---|
| Page behind login | Google's docs list robots.txt, noindex and login requirements as blockers |
| Only an .m3u8 URL | Sitemap content must be reachable over HTTP or FTP; streaming protocols unsupported |
| Same URL for loc and content_loc | Docs say they must differ |
| Unrelated videos in a sitemap entry | Docs say not to list videos unrelated to the host page |
Wrapping up
Next: add Clip markup for key moments (Google's docs say the video must be at least 30 seconds) and BroadcastEvent for live. Check results in Search Console after Google recrawls.


