How We Added Structured Data to SendRec Watch Pages
Last week we added Open Graph and Twitter Card meta tags so SendRec links render rich previews on Slack, Twitter, and LinkedIn. That solved the social sharing problem. But there’s another audience that reads your page’s metadata: search engines.
Google supports a special type of structured data called VideoObject that tells crawlers “this page contains a video, here’s its title, duration, thumbnail, and embed URL.” When Google understands this, it can show your video as a rich result in search — with a thumbnail, duration badge, and play button directly in the SERP.
We added VideoObject JSON-LD, canonical URLs, and meta descriptions to every SendRec watch page. Here’s how.
What JSON-LD looks like
JSON-LD (JavaScript Object Notation for Linked Data) is a <script> tag in the page <head> that contains structured metadata:
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "Product Demo",
"description": "Alex walks through the new dashboard layout and settings.",
"thumbnailUrl": "https://app.sendrec.eu/api/watch/abc123/thumbnail",
"uploadDate": "2026-02-22T14:30:00Z",
"duration": "PT2M34S",
"embedUrl": "https://app.sendrec.eu/embed/abc123",
"contentUrl": "https://app.sendrec.eu/api/watch/abc123/download"
}
Search engines parse this and understand that the page hosts a 2-minute 34-second video uploaded on February 22nd, with a specific thumbnail and embed URL. This is much more precise than trying to infer the same information from the HTML.
Building the JSON-LD in Go
SendRec’s watch pages are server-rendered Go templates. The naive approach would be to write the JSON directly in the template:
<script type="application/ld+json">
{"@type":"VideoObject","name":"{{.Title}}","description":"{{.Description}}"}
</script>
This breaks. Go’s html/template package is context-aware — it knows when it’s rendering inside a <script> tag and applies JavaScript escaping rules. URLs like https:// become https:\/\/. Quotes get escaped differently than in HTML attributes. The resulting JSON is invalid.
The fix is to build the JSON in Go code and pass it to the template as a template.JS type, which tells the template engine “this is pre-escaped, don’t touch it”:
func buildVideoObjectJSONLD(
title, description, baseURL, shareToken, uploadDate, iso8601Dur string,
downloadEnabled, hasThumbnail bool,
) string {
obj := map[string]string{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": title,
"description": description,
"uploadDate": uploadDate,
"embedUrl": baseURL + "/embed/" + shareToken,
}
if hasThumbnail {
obj["thumbnailUrl"] = baseURL + "/api/watch/" + shareToken + "/thumbnail"
}
if iso8601Dur != "" {
obj["duration"] = iso8601Dur
}
if downloadEnabled {
obj["contentUrl"] = baseURL + "/api/watch/" + shareToken + "/download"
}
b, _ := json.Marshal(obj)
return string(b)
}
Using json.Marshal has a security benefit too — it properly escapes any special characters in user-controlled strings like the title or description. If someone sets their video title to "</script><script>alert(1)", json.Marshal escapes it safely.
The template just renders the pre-built string:
<script type="application/ld+json">{{.JSONLD}}</script>
ISO 8601 duration format
Schema.org expects durations in ISO 8601 format: PT2M34S means “period of time: 2 minutes, 34 seconds.” This is different from the 2:34 format we show in the video player.
We already had formatDuration for the player timestamp. We added formatISO8601Duration alongside it:
func formatISO8601Duration(totalSeconds int) string {
if totalSeconds <= 0 {
return ""
}
h := totalSeconds / 3600
m := (totalSeconds % 3600) / 60
s := totalSeconds % 60
if h > 0 {
return fmt.Sprintf("PT%dH%dM%dS", h, m, s)
}
if m > 0 {
return fmt.Sprintf("PT%dM%dS", m, s)
}
return fmt.Sprintf("PT%dS", s)
}
Zero or negative durations return an empty string, which causes the duration field to be omitted from the JSON-LD entirely. Schema.org validators accept VideoObjects without duration — it’s a recommended property, not required.
Conditional fields
Not every video has the same metadata. Some have thumbnails, some don’t. Some allow downloads, some don’t. Some have known durations, some are still processing.
The buildVideoObjectJSONLD function handles this with conditional inclusion:
thumbnailUrl— only when a thumbnail exists. We use the same proxy endpoint from last week’s OG tags work, so the URL never expires.duration— only when greater than zero. Videos that are still processing may not have a duration yet.contentUrl— only when downloads are enabled. This points to the download endpoint. If downloads are disabled, we don’t advertise a direct content URL.embedUrl— always included. Every video has an embeddable player at/embed/{shareToken}.
Canonical URLs
We also added a <link rel="canonical"> tag:
<link rel="canonical" href="https://app.sendrec.eu/watch/abc123">
This tells search engines “this is the authoritative URL for this content.” If the same video appears at multiple URLs (with query parameters, tracking codes, or different protocols), the canonical tag prevents duplicate content issues and consolidates ranking signals to the single correct URL.
Meta description
The existing og:description tag was already computed with a three-level fallback (AI summary, “Video by {creator} ({duration})”, or title). But Open Graph tags are for social platforms — search engines prefer the standard <meta name="description"> tag for SERP snippets. We added it with the same value:
<meta name="description" content="Video by Alex Neamtu (2:34)">
Testing
The JSON-LD tests verify both the presence of required schema.org fields and the conditional behavior:
func TestWatchPage_JSONLDVideoObject(t *testing.T) {
// ... setup with thumbnail, summary, 154-second duration ...
checks := []string{
`application/ld+json`,
`"@context":"https://schema.org"`,
`"@type":"VideoObject"`,
`"name":"JSON-LD Test"`,
`"uploadDate":"2026-02-05T14:30:00Z"`,
`"duration":"PT2M34S"`,
`"embedUrl":"` + testBaseURL + `/embed/` + shareToken + `"`,
`"thumbnailUrl":"` + testBaseURL + `/api/watch/` + shareToken + `/thumbnail"`,
}
for _, check := range checks {
if !strings.Contains(body, check) {
t.Errorf("expected %q in response body", check)
}
}
}
Separate tests verify that contentUrl appears when downloads are enabled and is absent when they’re disabled. The ISO 8601 formatter has its own table-driven test with 8 cases covering seconds-only, minutes, and hours.
What search engines see now
Every SendRec watch page now provides:
- JSON-LD VideoObject — structured data that enables rich video results in Google Search
- Canonical URL — prevents duplicate content issues
- Meta description — clean SERP snippet with video creator and duration
- Open Graph tags — rich previews on social platforms (from last week)
- Twitter Card tags — large image cards on Twitter (from last week)
If you’re self-hosting SendRec, this works automatically. The structured data uses your configured BASE_URL, so all URLs resolve correctly regardless of your domain.
You can verify the markup on any watch page using Google’s Rich Results Test — paste a watch page URL and it will show the parsed VideoObject with all its properties.