Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

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.

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.