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.
Top comments (0)