DEV Community

Mason K
Mason K

Posted on

Generate VideoObject JSON-LD and a video sitemap from your video API data (Node.js)

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",
};
Enter fullscreen mode Exit fullscreen mode

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` : "");
}
Enter fullscreen mode Exit fullscreen mode
$ node -e "import('./iso-duration.js').then(m=>console.log(m.isoDuration(114), m.isoDuration(750), m.isoDuration(3600)))"
PT1M54S PT12M30S PT1H
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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>
`;
}
Enter fullscreen mode Exit fullscreen mode

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)?$/);
});
Enter fullscreen mode Exit fullscreen mode
$ node --test
✔ required VideoObject fields present
✔ duration is ISO 8601
Enter fullscreen mode Exit fullscreen mode

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)