
Next.js Using and optimizing videos
Images get a dedicated, highly-optimized next/image component with automatic format conversion, resizing, and lazy loading built in. Video gets no equivalent first-party component — and that's a deliberate gap, not an oversight, given how much more variable video hosting requirements are compared to images. This article covers the two fundamental embedding approaches Next.js supports, when self-hosting is actually worth the operational overhead, and the third-party services that fill the gap where a built-in component doesn't exist.
Two embedding approaches, two very different trade-offs
The <video> tag embeds self-hosted or directly-served video files, giving you complete control over playback and appearance:
export function Video() {
return (
<video width="320" height="240" controls preload="none">
<source src="/path/to/video.mp4" type="video/mp4" />
<track
src="/path/to/captions.vtt"
kind="subtitles"
srcLang="en"
label="English"
/>
Your browser does not support the video tag.
</video>
);
}
The attributes worth actually understanding rather than copy-pasting: preload controls how aggressively the browser fetches the video ahead of playback (none, metadata, or auto), and none is the right default for anything not immediately front-and-center on the page — no point burning bandwidth downloading a video the user may never actually watch. autoPlay and muted are a package deal worth remembering together: most browsers simply won't autoplay video with sound, as a deliberate, longstanding anti-annoyance policy — if you want autoplay to actually work, muted needs to accompany it, full stop. playsInline matters specifically for iOS Safari, where without it, a video can force itself into fullscreen playback rather than playing inline as you'd expect — often necessary for autoplay to behave correctly on iOS at all.
The <iframe> tag embeds externally-hosted video from a platform like YouTube or Vimeo:
export default function Page() {
return (
<iframe src="https://www.youtube.com/embed/19g66ezsKAg" allowFullScreen />
);
}
The trade-off here is precisely the mirror image of the <video> approach: you give up fine-grained control over playback and appearance, in exchange for the hosting platform handling encoding, adaptive bitrate streaming, and global CDN delivery entirely on your behalf, for free.
Choosing between them isn't really a technical question
It's a question about how much control you actually need versus how much operational overhead you're willing to take on. Self-hosting via <video> makes sense when you need genuine, fine-grained control — a custom player UI, tight design integration, dynamic background video — and are prepared to handle storage, bandwidth, and delivery yourself, one way or another. An <iframe> embed makes sense when you just need a video in the page and don't especially care about controlling its exact presentation, in exchange for essentially zero infrastructure to think about.
Streaming an externally-hosted video without blocking the page
If fetching the video's source URL itself requires a server round-trip — resolving a video ID against an API, say — that fetch shouldn't block the rest of the page from rendering. The pattern: a Server Component that resolves the source, wrapped in Suspense so the rest of the page stays interactive while it streams in:
// app/ui/video-component.jsx
export default async function VideoComponent() {
const src = await getVideoSrc();
return <iframe src={src} allowFullScreen />;
}
// app/page.jsx
import { Suspense } from "react";
import VideoComponent from "../ui/VideoComponent.jsx";
export default function Page() {
return (
<section>
<Suspense fallback={<p>Loading video...</p>}>
<VideoComponent />
</Suspense>
</section>
);
}
This is precisely the streaming pattern covered in depth elsewhere in this series — a genuine loading skeleton shaped like the eventual video player, rather than a bare "Loading video..." text string, is worth the small extra effort here specifically because layout-shift matters: a skeleton matching the video player's actual dimensions means the surrounding page doesn't visibly jump once the real content lands.
Self-hosting: what it actually buys you, and what it costs
Self-hosting is worth choosing deliberately for a specific set of reasons, not by default: complete, independent control over your content with no external platform's constraints or branding intruding on it; genuine customization for unusual requirements like a dynamic, data-driven background video a third-party embed simply couldn't accommodate; and — the part that's easy to underestimate going in — real, ongoing responsibility for choosing storage and delivery infrastructure that can actually scale with your traffic and content volume, plus balancing the resulting cost against how well it integrates with the rest of your stack.
A worked example: Vercel Blob
Vercel Blob is one reasonably well-integrated option for a Next.js project specifically, and it's worth walking through end-to-end since the pattern generalizes to other object-storage services too.
Upload a video via the Vercel dashboard's Storage tab, or programmatically via a Server Action for a more automated upload flow — both server-side and client-side upload paths are supported, and which one fits depends on whether you want uploads flowing through your own server or going directly from the browser.
Displaying the uploaded file combines the same Suspense-streaming pattern from above with a lookup against the Blob store:
// app/page.jsx
import { Suspense } from "react";
import { list } from "@vercel/blob";
export default function Page() {
return (
<Suspense fallback={<p>Loading video...</p>}>
<VideoComponent fileName="my-video.mp4" />
</Suspense>
);
}
async function VideoComponent({ fileName }) {
const { blobs } = await list({ prefix: fileName, limit: 1 });
const { url } = blobs[0];
return (
<video controls preload="none" aria-label="Video player">
<source src={url} type="video/mp4" />
Your browser does not support the video tag.
</video>
);
}
Adding subtitles follows the identical pattern — fetch a second blob (the caption file) alongside the video itself, and wire it into a <track> element:
async function VideoComponent({ fileName }) {
const { blobs } = await list({ prefix: fileName, limit: 2 });
const { url } = blobs[0];
const { url: captionsUrl } = blobs[1];
return (
<video controls preload="none" aria-label="Video player">
<source src={url} type="video/mp4" />
<track src={captionsUrl} kind="subtitles" srcLang="en" label="English" />
Your browser does not support the video tag.
</video>
);
}
One genuinely useful operational detail worth knowing: with a storage solution like Vercel Blob specifically, CDN delivery is handled automatically as part of the service — you're not separately standing up and configuring a CDN layer on top of your storage just to get reasonable global delivery performance.
Accessibility isn't optional, for either embedding method
For <video>: always include fallback content inside the tag for browsers that genuinely can't play video at all (the plain text between the tags in every example above is exactly that fallback). Include subtitles or captions via <track> for deaf or hard-of-hearing users — this isn't a nice-to-have add-on, it's a core accessibility requirement for any video carrying meaningful spoken content. And prefer the standard HTML5 controls for their built-in keyboard navigation and screen reader compatibility; if you need something more elaborate than the native controls provide, reach for an accessible third-party player like react-player or video.js rather than hand-rolling custom controls that are easy to get accessibility wrong on.
For <iframe>: always set a title attribute — screen reader users rely on it to understand what an embedded iframe actually contains before deciding whether to interact with it, and a missing title leaves them with no context at all about what they're about to tab into.
Format, compression, and delivery, briefly
A few practical technical notes worth knowing even if you're not deep in video engineering day to day: MP4 remains the safest choice for broad browser compatibility; WebM is more web-optimized but with narrower support. FFmpeg is the standard, genuinely excellent tool for compressing video effectively — balancing visual quality against file size rather than shipping an unnecessarily large file that costs your users bandwidth for no perceptible quality benefit. Match resolution and bitrate to your actual audience's likely viewing context — a lower bitrate genuinely makes sense for content you know will be predominantly viewed on mobile connections, rather than defaulting to one universal quality setting regardless of who's actually watching. And use a CDN for delivery specifically to improve speed and handle traffic spikes gracefully — as noted above, some storage solutions (Vercel Blob among them) already fold this in automatically, so it's worth checking before assuming you need to configure a separate CDN layer yourself.
Dedicated video platforms worth knowing about
If self-hosting via raw <video> tags feels like more infrastructure than you want to own, several services provide purpose-built Next.js integrations specifically for video, each worth a look depending on your actual constraints:
next-video is an open-source <Video> component compatible with multiple hosting backends — Vercel Blob, S3, Backblaze, Mux — worth a look if you want a unified component API without locking into one specific hosting provider underneath it.
Cloudinary offers a <CldVideoPlayer> component for genuinely drop-in video support, plus documented examples of more advanced patterns like adaptive bitrate streaming for varying network conditions.
Mux provides a dedicated video API alongside a full starter template for building a video course platform specifically — worth a look if that's close to your actual use case rather than a generic video embed.
Fastly and ImageKit.io both offer their own video-on-demand and streaming integrations, worth checking if you're already using either provider elsewhere in your stack and want to consolidate rather than add yet another vendor.
Key Takeaways
| Situation | Approach |
|---|---|
| Need full control over playback/appearance | Self-hosted <video> |
| Just need a video embedded, don't need custom control | <iframe> (YouTube, Vimeo, etc.) |
| Fetching a video source requires a server round-trip | Server Component + <Suspense>, same pattern as any streamed data |
| Self-hosting without owning full infrastructure | Vercel Blob, or another object-storage service with built-in CDN |
Want a managed video platform instead of raw <video> | next-video, Cloudinary, Mux, Fastly, or ImageKit.io |
| Accessibility for either method | <track> captions for <video>; a title attribute for <iframe> |
There's no single "right" way to add video to a Next.js app the way there's a clear default answer for images — the actual decision runs through how much control you need, how much infrastructure you're willing to own, and whether an existing service already fits your stack. The one universal constant across every path: don't let a video's data fetch block the rest of the page, and don't ship it without captions or a title attribute.


