Today we went from "should the homepage have a hero video?" to "which of the 50 candidates ships?" to "it's live." Along the way we hit every recurring gotcha of working with stock video on a static site: bot-protected catalogs, undocumented CDN URLs, 400 MB of binary bloat threatening the repo, and browsers quietly giving up when you pile too many <video> tags on one page.
This is the case study.
The setup
The old hero was a faded circular headshot. It worked, but felt static. We wanted motion — calm, cinematic, natural — without turning the page into a bandwidth hog.
Constraint: no local video files committed to git. The repo stays lean, Vercel bandwidth stays cheap, Core Web Vitals stay green.
Where to find free stock video
Three sources that consistently deliver usable footage:
- Pexels — huge catalog, honest license (free, attribution appreciated not required), and a real public API with direct MP4 URLs. This is the only one that's programmatically searchable without reverse-engineering.
- Coverr — hand-curated hero-friendly loops. No API, but each video page streams straight from
cdn.coverr.co. Great for taste, painful for automation. - Mixkit — no-attribution license, decent quality. Download buttons are JS-gated so scraping is fragile; best for manual browsing.
Pexels wins for any workflow that needs scale.
How we actually loaded them
Grab a free API key from pexels.com/api/new — it arrives in seconds — and hit this endpoint:
GET https://api.pexels.com/videos/search
?query=coast+aerial
&orientation=landscape
&size=large
&per_page=30
Authorization: <your-key>
The response gives you a list of videos, each with a video_files array of MP4 renditions at different resolutions. A tiny Node script picks the highest rendition per video and writes a manifest:
const res = await fetch(
`https://api.pexels.com/videos/search?query=${encodeURIComponent('coast aerial')}&per_page=30&size=large`,
{ headers: { Authorization: process.env.PEXELS_API_KEY } }
);
const { videos } = await res.json();
const picks = videos.map(v => {
const best = v.video_files
.filter(f => f.file_type === 'video/mp4')
.sort((a, b) => b.height - a.height)[0];
return { id: v.id, url: best.link, poster: v.image, author: v.user.name };
});
50 clips across 6 categories (mountain drone, ocean waves, forest mist, aurora, sunset clouds, coast aerial) — written to a single JSON the prototype picker page consumed.
The part we learned the hard way
First instinct: download the 50 clips locally, commit them to the repo, embed them with <video src="/videos/x.mp4">. Cost: 406 MB. Vercel's deploy limit balks, git history bloats forever, and every visitor downloads a chunk of that for the hero alone.
The fix is trivial in retrospect: the MP4 URLs the Pexels API returns are permanent, publicly accessible CDN URLs at videos.pexels.com. Point <source src> straight at them. Zero repo bytes, Pexels pays the bandwidth.
<video autoplay muted loop playsinline
poster="https://images.pexels.com/videos/36519600/pexels-photo-36519600.jpeg?auto=compress&cs=tinysrgb&fit=crop&h=1080&w=1920">
<source
src="https://videos.pexels.com/video-files/36519600/15485595_3840_2160_60fps.mp4"
type="video/mp4" />
</video>
That's the actual hero, live on the homepage right now.
See for yourself
Three of the 50 candidates we evaluated — streamed directly from Pexels, no bytes shipped from this page:
1. Coast aerial — the winner
Mallorca cliffs from above. Calm but moving. By Mike Art on Pexels.
2. Aurora borealis — the dramatic alternative
Great on a landing page; too theatrical for the calm hero we wanted.
3. Coding shot — different vibe entirely
Briefly auditioned for an "Experiment Pipeline" tile. Nice on its own; fought for attention next to the hero.
Takeaways
- Use the Pexels API. It's the only frictionless path for searching at scale. Coverr and Mixkit are fine for one-off picks via their websites.
- Don't commit video files. Point
<source src>at the vendor CDN. Treat stock video like stock images — a remote resource, not a repo asset. - Prototype before you ship. A disposable
/prototype/video-preview.htmlpage cycling through all 50 candidates with filter chips let us pick confidently in one sitting. Throwaway scaffolding, zero risk to the real app. - Watch for browser decoder limits. Keeping 50 live
<video>elements in the DOM made clip #8 onward stall on its poster image. Chrome caps concurrent video decoders around eight; Safari is lower. We capped the live pool at 16 and LRU-evicted the rest.
Total: ~3 hours of iteration, 50 clips evaluated, zero bytes of video committed. The clip that shipped has streamed ~3 MB to each visitor so far — the same cost as a single decent-quality hero image.