How We Added Rich Link Previews to SendRec
You record a video, copy the link, and paste it into Slack. What shows up? A bare URL. No thumbnail, no title, no context. Your teammate has to click to find out what it is.
That’s what SendRec looked like until today. We had partial Open Graph tags — enough for og:title and a thumbnail — but the thumbnail was a presigned S3 URL that expired after one hour. Social platforms cache meta tags for hours or days. By the time Slack, Twitter, or LinkedIn tried to fetch the image, the URL was dead.
We fixed this by adding a thumbnail proxy endpoint, complete Open Graph tags, and Twitter Card support. Here’s how.
The presigned URL problem
SendRec stores video thumbnails in S3-compatible storage (Garage). To serve them, we generate presigned URLs — signed query strings that grant temporary access to private objects. Our presigned URLs expire after one hour.
This works for the video player. The page loads, the browser fetches the thumbnail, done. But social platforms work differently:
- You paste a link into Slack
- Slack’s crawler fetches the page and reads the
og:imageURL - Slack caches the result for hours or days
- When someone else opens the channel later, Slack serves the cached preview
- The
og:imageURL has expired — broken thumbnail
The same happens on Twitter, LinkedIn, Discord, and every platform that renders link previews. The og:image URL needs to be stable. It can’t expire.
The thumbnail proxy
Instead of putting the presigned URL directly in the og:image tag, we added a public endpoint that generates a fresh presigned URL on every request and redirects to it:
GET /api/watch/{shareToken}/thumbnail
→ 302 redirect to presigned S3 URL (1-hour expiry)
The og:image tag now points to this proxy URL:
<meta property="og:image" content="https://app.sendrec.eu/api/watch/abc123/thumbnail">
This URL never expires. Every time a crawler (or browser) requests it, the handler generates a fresh presigned URL and redirects. The crawler follows the redirect and gets the image.
Here’s the handler:
func (h *Handler) WatchThumbnail(w http.ResponseWriter, r *http.Request) {
shareToken := chi.URLParam(r, "shareToken")
var thumbnailKey *string
var shareExpiresAt *time.Time
err := h.db.QueryRow(r.Context(),
`SELECT v.thumbnail_key, v.share_expires_at
FROM videos v
WHERE v.share_token = $1 AND v.status IN ('ready', 'processing')`,
shareToken,
).Scan(&thumbnailKey, &shareExpiresAt)
if err != nil {
http.NotFound(w, r)
return
}
if shareExpiresAt != nil && time.Now().After(*shareExpiresAt) {
http.NotFound(w, r)
return
}
if thumbnailKey == nil {
http.NotFound(w, r)
return
}
url, err := h.storage.GenerateDownloadURL(r.Context(), *thumbnailKey, 1*time.Hour)
if err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, url, http.StatusFound)
}
Three safety checks before the redirect:
- The video must exist and be in a valid state
- If the share link has an expiration, it must not have passed
- A thumbnail must actually exist
If any check fails, return 404. Social platforms handle 404s gracefully — they just don’t show an image.
We chose 302 (temporary redirect) over 301 (permanent) deliberately. A 301 tells caches “this redirect is permanent, don’t ask again.” But the presigned URL changes every time, so we need the crawler to come back to our proxy on every request.
Open Graph meta tags
Before this change, the watch page had three OG tags:
<meta property="og:title" content="Product Demo">
<meta property="og:type" content="video.other">
<meta property="og:image" content="https://storage.sendrec.eu/...?X-Amz-Expires=3600&...">
Now it has the full set:
<!-- Open Graph -->
<meta property="og:title" content="Product Demo">
<meta property="og:type" content="video.other">
<meta property="og:description" content="Video by Alex Neamtu (2:34)">
<meta property="og:url" content="https://app.sendrec.eu/watch/abc123">
<meta property="og:image" content="https://app.sendrec.eu/api/watch/abc123/thumbnail">
<meta property="og:site_name" content="SendRec">
<meta property="og:video:width" content="1920">
<meta property="og:video:height" content="1080">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Product Demo">
<meta name="twitter:description" content="Video by Alex Neamtu (2:34)">
<meta name="twitter:image" content="https://app.sendrec.eu/api/watch/abc123/thumbnail">
The twitter:card value summary_large_image tells Twitter to render a large preview with the image spanning the full width of the card, rather than a small square thumbnail.
The description fallback chain
Not every video has a useful description. Some have AI-generated summaries, some don’t. We use a three-level fallback:
description := summaryStr
if description == "" {
if duration > 0 {
description = fmt.Sprintf("Video by %s (%s)", creator, formatDuration(duration))
} else {
description = title
}
}
-
AI summary — if the video has been summarized and the summary is ready, use it. This gives the richest preview: “Alex demonstrates the new dashboard layout, explains the navigation changes, and walks through the updated settings page.”
-
“Video by {Creator} ({duration})” — if there’s no summary, show who made the video and how long it is. “Video by Alex Neamtu (2:34)” tells you enough to decide whether to click.
-
Title — last resort, if duration isn’t available.
The duration required adding v.duration to the watch page SQL query. The column already existed in the database — it’s set when the video is processed — we just weren’t selecting it for the watch page.
Duration formatting
We needed to format seconds as MM:SS or H:MM:SS. The same logic was already in the watch page’s JavaScript for the video player timestamp display. We added a Go version:
func formatDuration(totalSeconds int) string {
if totalSeconds >= 3600 {
return fmt.Sprintf("%d:%02d:%02d",
totalSeconds/3600,
(totalSeconds%3600)/60,
totalSeconds%60,
)
}
return fmt.Sprintf("%d:%02d", totalSeconds/60, totalSeconds%60)
}
This is also used by the Go template’s formatTimestamp function, which was previously duplicating the same logic inline.
What platforms see now
When you paste a SendRec link into Slack, you get:
- Title: The video’s name
- Description: AI summary or “Video by {name} ({duration})”
- Thumbnail: A large preview image from the proxy endpoint
- Site name: SendRec
Twitter shows a large card with the thumbnail spanning the full width. LinkedIn renders the same tags. Discord picks up the OG tags and shows a rich embed.
What we didn’t build
Twitter player card: Twitter supports a player card type that embeds an inline video player directly in the tweet. This requires whitelisting with Twitter and would add complexity for minimal benefit — most users will click through to the watch page.
Per-video descriptions: We considered adding a description text field to videos, but it adds UI complexity and another thing for users to fill out. The fallback chain handles it well enough.
Embed page tags: The /embed/{token} endpoint is meant for iframes, not direct sharing. Adding OG tags there would be misleading — a crawler fetching an embed URL should not get a rich preview, because the URL is meant to be embedded, not visited directly.
Testing
The thumbnail proxy has four test cases: successful redirect, video not found, no thumbnail, and expired share link. Each one sets up the expected database query and verifies the response.
func TestWatchThumbnail_Redirects(t *testing.T) {
h, mock := newTestHandler()
thumbnailKey := "thumbnails/abc.jpg"
mock.ExpectQuery("SELECT v.thumbnail_key").
WithArgs("validtoken").
WillReturnRows(pgxmock.NewRows([]string{"thumbnail_key", "share_expires_at"}).
AddRow(&thumbnailKey, nil))
rr := serveWatchThumbnail(h, "validtoken")
if rr.Code != http.StatusFound {
t.Fatalf("expected 302, got %d", rr.Code)
}
loc := rr.Header().Get("Location")
if !strings.Contains(loc, "thumbnails/abc.jpg") {
t.Fatalf("expected redirect to thumbnail, got %s", loc)
}
}
The OG and Twitter Card tags are tested by rendering the watch page and checking for the expected meta tag values — the proxy URL in og:image, the description fallback, and the presence of all Twitter Card tags.
The result
Every SendRec link now renders a rich preview across every platform that supports Open Graph or Twitter Cards. The thumbnail proxy solves the presigned URL expiry problem without making the S3 bucket public or adding a caching layer. It’s a single SQL query and a 302 redirect — about as simple as it gets.
If you’re self-hosting SendRec, the feature works out of the box. The proxy URL uses your configured BASE_URL, so thumbnails resolve correctly regardless of your domain.