Mert Özen Further With Every Line
SEO & GEO Technical SEO — #14

Video SEO: Watch Pages, Video Sitemaps, VideoObject, Key Moments with Clip and SeekToAction, and Thumbnail Rules

Mert Özen Nov 15, 2026 12 min 5 views
Video SEO: Watch Pages, Video Sitemaps, VideoObject, Key Moments with Clip and SeekToAction, and Thumbnail Rules

For video features, each video needs one watch page; the video must be in the initial HTML without waiting for a click, and the sitemap, VideoObject and page must state the same thumbnail, title and duration. On Bookstore's video pages we build the video sitemap, key moments with Clip and SeekToAction, the ?t= parameter, stable thumbnail URLs and expiring, regional videos in ASP.NET Core and pin them down with tests.

Version note: The code in this post was built and tested with .NET 10 (ASP.NET Core 10.0.15); the rules follow Google's video SEO guide (last updated 18 December 2025), the video sitemap page (20 May 2026) and the video structured data documentation (24 September 2026) as read in November 2026.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, has recorded a "Dune Reading Guide" video. The video appears in three places: on Dune's book page, on the /videos page that lists all videos, and on its own page, /videos/dune-reading-guide. Marketing has two requests: mark the video up with VideoObject on all three pages so that "Google can pick whichever it wants", and load the video only when a preview image is clicked so the player doesn't slow down the page.

Two questions. Which page will Google associate the video with? And will Google see the video in a player that loads on click?

In the previous post we saw that images should be served with a single stable URL and the same information on the page, in the sitemap and in structured data. Video adds a layer to the same principle: a video has three URLs, and each one means something different to Google.

The Watch Page: Where the Video Is the Main Content

At the center of Google's video guide is the watch page: a page whose main purpose is to let the user watch a single video. A video's own page, the player page of a TV episode or a sports highlight page are watch pages. A blog post reviewing a video, a product page with a 360-degree video, a category page listing many videos or a film review with an embedded trailer aren't; there the video complements the main content of the page.

The guide recommends a dedicated watch page for each video, where it makes sense for the business, to be eligible for features such as video results, video mode, key moments and the LIVE badge, and it lists the conditions: the watch page must be indexed and performing well in Search, the video must be embedded on the page and not hidden behind other elements, and it must have a valid thumbnail at a stable URL. An indexed page doesn't mean the video will be indexed too.

Having the same video on pages other than the watch page isn't a problem; according to the guide such a page can still appear as a text result or as an image result with a video badge. Bookstore's decision: VideoObject only on the watch page. The book page and the video list show the video's thumbnail and link to the watch page. A test pins it down:

[Theory]
[InlineData("/books/dune")]
[InlineData("/videos")]
public async Task Pages_that_only_show_or_list_a_video_are_not_marked_as_watch_pages(string path)
{
    IDocument page = await PageAsync(path);
    Assert.Null(VideoObject(page));
    Assert.Contains(page.QuerySelectorAll("a"), a => a.GetAttribute("href")!.StartsWith("/videos/"));
}

That's also the answer to the first question in the opening: marking up all three pages doesn't leave the choice to Google, it says "all three of these pages are this video's page". The right signal is to point clearly to the video's one main page. The JSON-LD mechanics of the markup are in the second and fourth posts of the Structured Data and Schema.org series; this post looks at video as a whole.

How Does Google Find a Video?

The guide asks for the video to be embedded with <video>, <embed>, <iframe> or <object>; loading the video through a URL fragment (#) isn't supported. The second request in the opening is exactly what the guide rules out: loading the video must not depend on a user action such as swiping, clicking or typing. The "load the player on click" pattern (a facade) is common for performance, but since Google doesn't click, it sees no video on that page. If the video is injected with JavaScript, make sure it appears in the rendered HTML in URL Inspection; if a media API is used, the video container has to stay in the HTML even when the API call fails.

On Bookstore's watch page the video is in the initial HTML, without lazy: <video> for files on our own server, <iframe> for the YouTube video:

@if (video.ContentUrl is not null)
{
    <video controls preload="metadata" poster="@Model.ThumbnailUrl" width="1280" height="720">
        <source src="@(start.Length > 0 ? $"{video.ContentUrl}#t={start}" : video.ContentUrl)" type="video/mp4">
    </video>
}
else
{
    <iframe src="@(start.Length > 0 ? $"{video.PlayerUrl}?start={start}" : video.PlayerUrl)" title="@video.Title"
            width="1280" height="720" allow="encrypted-media; picture-in-picture" allowfullscreen></iframe>
}

start comes from the ?t= parameter of the page URL; we'll come back to why that's needed in the key moments section.

Three URLs: Watch Page, Player and File

The guide defines three separate URLs for a video, and each one maps to a different field in the sitemap and in structured data:

URLVideo sitemapVideoObjectBookstore example
Watch page<loc>the page itselfhttps://bookstore.example/videos/dune-reading-guide
Player<video:player_loc>embedUrlhttps://www.youtube-nocookie.com/embed/bkFoundat12
Video file<video:content_loc>contentUrlhttps://media.bookstore.example/videos/dune-reading-guide.mp4

Google prefers the file itself (the content URL): the documentation describes it as the most effective way for Google to fetch the video, with the player URL only as a fallback when the file can't be provided. For videos embedded from a platform such as YouTube or Vimeo you don't have the file, so the player URL is used. According to the guide, Google may then index the video both on your page and on the platform's own page; providing structured data for your page is still recommended.

Video Sitemaps

The required fields of a video sitemap are <video:thumbnail_loc>, <video:title>, <video:description> and at least one of <video:content_loc> and <video:player_loc>. The description is at most 2048 characters; both URLs must differ from <loc>, that is, from the watch page. Among the optional fields, the duration is a number between 1 and 28800 seconds. In May 2022 <video:category>, <video:gallery_loc>, <video:price>, <video:tvshow> and the autoplay and allow_embed attributes of player_loc were removed from the documentation. Bookstore's writer is a sibling of the sitemap writer from the fourth post of the series:

await xml.WriteStartElementAsync("video", "video", VideoNs);
await Element(xml, "thumbnail_loc", site.AbsoluteImageBase + video.ThumbnailPath);
await Element(xml, "title", video.Title);
await Element(xml, "description", video.Description);
// content_loc is preferred; player_loc only when there's just a player
if (video.ContentUrl is not null)
    await Element(xml, "content_loc", video.ContentUrl);
if (video.PlayerUrl is not null)
    await Element(xml, "player_loc", video.PlayerUrl);
await Element(xml, "duration", video.DurationSeconds.ToString());
await Element(xml, "publication_date", video.UploadDate.ToString("yyyy-MM-ddTHH:mm:sszzz"));
$ curl -s http://127.0.0.1:52213/sitemaps/videos.xml | xmllint --format - | head -14
<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns:video="http://www.google.com/schemas/sitemap-video/1.1" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://bookstore.example/videos/dune-reading-guide</loc>
    <video:video>
      <video:thumbnail_loc>https://cdn.bookstore.example/images/videos/dune-reading-guide-1280x720.jpg</video:thumbnail_loc>
      <video:title>Dune Reading Guide: Where to Start and What to Watch For</video:title>
      <video:description>A spoiler-free guide to Frank Herbert's Dune: the setting, the main houses and which edition to pick.</video:description>
      <video:content_loc>https://media.bookstore.example/videos/dune-reading-guide.mp4</video:content_loc>
      <video:duration>754</video:duration>
      <video:publication_date>2026-10-12T09:00:00+03:00</video:publication_date>
    </video:video>
  </url>
  <url>

The video's Turkish and English watch pages (two language versions linked with hreflang) are separate <url> entries in the sitemap; both carry the same video information.

VideoObject: The Same Information as the Sitemap and the Page

On the structured data side, the required VideoObject fields are name, thumbnailUrl and uploadDate. The documentation recommends adding a time zone to uploadDate; without one, Googlebot's time zone is assumed. contentUrl, embedUrl, description and duration are recommended fields; in September 2026 creator (or author) was added to the recommended ones. The main point of the documentation here is consistency: the markup must match the actual video and the other metadata, and every video should have its own name, description and thumbnailUrl.

Consistency isn't something to maintain by hand. In Bookstore the sitemap, the JSON-LD and the page are generated from the same Video record, and a test compares all three:

string thumbnail = entry.Element(V + "thumbnail_loc")!.Value;
Assert.Equal(thumbnail, ld.GetProperty("thumbnailUrl").GetString());
Assert.Equal(thumbnail, page.QuerySelector("meta[property='og:image']")!.GetAttribute("content"));
Assert.Equal(entry.Element(V + "title")!.Value, ld.GetProperty("name").GetString());
Assert.Equal(page.QuerySelector("h1")!.TextContent, ld.GetProperty("name").GetString());

int seconds = int.Parse(entry.Element(V + "duration")!.Value);
Assert.Equal(seconds, (int)XmlConvert.ToTimeSpan(ld.GetProperty("duration").GetString()!).TotalSeconds);
Assert.InRange(seconds, 1, 28_800);
Assert.True(entry.Element(V + "description")!.Value.Length <= 2048);

The duration is in seconds in the sitemap (754) and an ISO 8601 duration in the JSON-LD (PT12M34S); XmlConvert is a safe bridge between the two. The test runs for each of the three videos.

Key Moments: Clip, SeekToAction and the YouTube Description

Key moments split a video into segments like the chapters of a book and take the user straight to the relevant moment. Google tries to find the segments itself, but it prioritizes the ones you provide through structured data or the YouTube description. There are three ways:

  • Clip: you provide the start, an optional end and a label for each segment. Supported in every language where Google Search is available.
  • SeekToAction: you tell Google where the time goes in your URL, and Google finds the segments. Supported in a limited set of languages, which includes Turkish (English, Spanish, Portuguese, Italian, Chinese, French, Japanese, German, Turkish, Korean, Dutch and Russian). Google must be able to fetch the video file.
  • A video hosted on YouTube: you write timestamps and labels in the YouTube description; one timestamp per line, with its label on the same line, in chronological order.

Shared conditions for Clip and SeekToAction: the video must be able to deep link to a point other than the start (such as ?t=30), the VideoObject must be on the page where it can be watched, and the video must be at least 30 seconds long. With Clip, two segments on the same page can't share a start time; the url of each Clip must be the video's URL path with a query parameter for the time added. Bookstore uses all three: Clip for the Dune guide whose chapters are known, SeekToAction for the campaign video that has no chapters but lives on our own server, and timestamps in the description for the YouTube video:

if (video.Chapters is { Length: > 0 } chapters)
{
    // we define the key moments: each chapter is a Clip, url is the same path + ?t=
    node["hasPart"] = chapters.Select((c, i) => new Dictionary<string, object>
    {
        ["@type"] = "Clip",
        ["name"] = c.Label,
        ["startOffset"] = c.StartSeconds,
        ["endOffset"] = i + 1 < chapters.Length ? chapters[i + 1].StartSeconds : video.DurationSeconds,
        ["url"] = $"{watchUrl}?t={c.StartSeconds}",
    }).ToArray();
}
else if (video.ContentUrl is not null)
{
    // no chapters: let Google find key moments, tell it where the time goes in the URL
    node["potentialAction"] = new Dictionary<string, object>
    {
        ["@type"] = "SeekToAction",
        ["target"] = $"{watchUrl}?t={{seek_to_second_number}}",
        ["startOffset-input"] = "required name=seek_to_second_number",
    };
}
// for the YouTube video, chapters come from the timestamps in the YouTube description

The chapters part of the markup produced by the Dune guide page:

$ curl -s http://127.0.0.1:52213/videos/dune-reading-guide | grep -o '<script type="application/ld+json">.*</script>' | sed -E 's/<[^>]+>//g' | jq '.hasPart[1:3]'
[
  {
    "@type": "Clip",
    "name": "Arrakis and the spice",
    "startOffset": 95,
    "endOffset": 310,
    "url": "https://bookstore.example/videos/dune-reading-guide?t=95"
  },
  {
    "@type": "Clip",
    "name": "The great houses",
    "startOffset": 310,
    "endOffset": 600,
    "url": "https://bookstore.example/videos/dune-reading-guide?t=310"
  }
]

These URLs have to work: a ?t=310 request must open the same page, the video must start at second 310, and the page must not look like a new URL. The watch page reads t and passes it to <source> as a media fragment (#t=310) and to the YouTube player as the start parameter; the canonical is always without the parameter:

$ curl -s "http://127.0.0.1:52213/videos/dune-reading-guide?t=310" | grep -E "canonical|<source"
        <link rel="canonical" href="https://bookstore.example/videos/dune-reading-guide">
            <source src="https://media.bookstore.example/videos/dune-reading-guide.mp4#t=310" type="video/mp4">

The chapters are also listed on the page as links such as <a href="?t=95">1:35 Arrakis and the spice</a>; the chapters the user sees and the ones in the markup come from the same list. To opt out of key moments entirely, use nosnippet.

Thumbnail Rules

The thumbnail requirements are in a single table in the guide: BMP, GIF, JPEG, PNG, WebP, SVG or AVIF; at least 60x30 pixels, larger preferred; an address Googlebot and Google's image crawler can reach, not blocked by robots.txt or a login; at least 80 percent of the pixels with an alpha value above 250, so practically opaque. The thumbnail can be given with the poster attribute, <video:thumbnail_loc>, thumbnailUrl or og:video:image, and the guide asks for the same URL in all of them.

Stability of the URL gets its own emphasis. Some CDNs produce signed URLs that expire quickly; if the thumbnail URL changes often, Google may not be able to index the video. For key moments and video previews, the video file has to be at a stable URL too. For those who don't want to expose the file to everyone, the guide suggests verifying Googlebot and serving contentUrl only to trusted clients (Googlebot verification is in the eleventh post of the series). In Bookstore the thumbnail path is derived from the video's slug and carries no parameters:

// one stable thumbnail URL per video: it doesn't change from deploy to deploy
public string ThumbnailPath => $"/images/videos/{Slug}-1280x720.jpg";

Expiring and Regional Videos, Livestreams

Bookstore's "Back to School 2026" campaign video will be taken down at the end of the year, and the campaign is only valid in Turkey. Both facts are generated from the same record in the JSON-LD (expires, regionsAllowed) and in the sitemap (<video:expiration_date>, <video:restriction>):

$ curl -s http://127.0.0.1:52213/sitemaps/videos.xml | xmllint --format - | grep -B2 -A1 restriction | head -4
      <video:publication_date>2026-08-20T10:00:00+03:00</video:publication_date>
      <video:expiration_date>2026-12-31</video:expiration_date>
      <video:restriction relationship="allow">TR</video:restriction>
    </video:video>

The guide's warning matters: a past date removes the video from video results, so don't give an expiration date to a video that stays online. If regionsAllowed is omitted, the video counts as eligible in all regions; the restriction only affects search results, not access to the video. The campaign video has no chapters and lives on our own server, so it carries SeekToAction.

Livestreams have a separate piece: BroadcastEvent added to VideoObject (isLiveBroadcast, startDate, endDate) makes the LIVE badge possible in results during the stream. The documentation recommends using the Indexing API for livestreams: when the stream starts, when it ends and when the markup changes. For video, the Indexing API supports livestreams only.

mertozen.com's Video Pages

This site's videos are on YouTube, and each video has a detail page (/videolar/{slug}). Going by the source code, against this post's checklist:

  • The detail page is a proper watch page: the YouTube player is an <iframe> in the initial HTML with loading="eager"; there's no click-to-load facade.
  • VideoObject gives the YouTube embed address as embedUrl and YouTube's hqdefault.jpg image as the thumbnail; that address is stable per video. og:image is the same image.
  • The /videolar list page also prints a VideoObject for every video on the page; as noted in the fourth post of the Structured Data series, a list page isn't a watch page.
  • When a video's publication date is empty in the CMS, DateTime.UtcNow is used for uploadDate; that's an upload date that changes on every request. Without a date, it's better not to emit the field at all or to use a fixed value.
  • The sitemap lists the video pages but has no video sitemap fields (<video:video>).
  • The only source for key moments is the timestamps in the YouTube descriptions; the site doesn't support a ?t= parameter, so Clip or SeekToAction isn't an option right now.

These are observations from the site's source code; what changes is the site owner's decision.

In Production

Keep a one-to-one relationship between watch page and video. Each video gets one main page, and that page's title and description are specific to the video. If a video has two language versions, they're two watch pages linked with hreflang, not two competing pages.

Generate the sitemap, the JSON-LD, poster and og:image from the same data record and compare them in tests. When the thumbnail URL, the title or the duration changes in one place but not in another, Google gets contradictory information.

Check that the server delivering the video files can handle crawling. The guide wants both the watch page's host and the streaming server to have enough capacity for crawling; streaming files such as M3U8 must not be blocked by robots.txt or noindex.

If you need to remove a video quickly, the way is a removal request in Search Console; meanwhile the watch page must stay closed to Google with a 404, noindex or server-side authentication.

Common Pitfalls

Loading the video on click. Google doesn't click; there's no video on that page.

Marking up the same video with VideoObject on many pages. The main page becomes unclear.

Signed thumbnail URLs that change per request or expire.

Canonicalizing time-parameter URLs such as ?t= as separate pages. The canonical must be the watch page without the parameter.

Giving a past expires date to a video that's still online.

Generating Clip starts from a hand-written list separate from the chapters shown on the page.

A Small Experiment

★☆☆ Pick three pages with video on your own site and answer one question for each: is the main purpose of this page to let the user watch a single video? Is VideoObject only on the pages where the answer is "yes"?

★★☆ Add a test to VideoSeoTests: a video's Clip starts and the ?t= links on the page must be the same list. Then change the start of one chapter only on the page and see what the test catches.

★★★ Decide what the watch page does once the campaign video's Expires date has passed, and implement it: if the video goes away, the page returns 410 and the video drops out of the sitemap; if it stays, the expires field isn't emitted at all. Show both cases with tests.

Images and video were the media inside the page. In the next post we move on to what grows the number of URLs fastest, catalog filters: we'll cover faceted navigation, pagination and infinite scroll without burning crawl budget, and set up the filter pages that should be indexed the right way.