TL;DR

We'll build two small Node.js modules: one renders VideoObject JSON-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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }[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.