TL;DR
Hosting video yourself means encoding pipelines, CDN distribution, adaptive bitrate streaming, and player maintenance. Video hosting APIs abstract this into a simple upload-and-stream workflow. Mux is the developer-first video infrastructure platform — upload a video, get HLS adaptive bitrate streams, thumbnails, GIF previews, captions, and per-second analytics; designed for SaaS products embedding video. Cloudflare Stream is the simplest and often cheapest option — deeply integrated into the Cloudflare network, streams through the same CDN as your other Cloudflare assets, and has a minimal but sufficient API. Bunny Stream is the most affordable at scale — European-based CDN with competitive per-GB pricing, video processing, and a built-in player with DRM options. For developer-first video products with analytics: Mux. For Cloudflare users wanting the simplest integration: Cloudflare Stream. For cost-sensitive applications with high video volume: Bunny Stream.
Key Takeaways
- Mux pricing: $0.015/minute stored + $0.045/1GB delivered — per-minute storage model
- Cloudflare Stream: $5/1,000 minutes stored + $1/1,000 minutes viewed — per-view model
- Bunny Stream: $0.0055/GB CDN + $0.02/minute encoding — lowest cost at high volume
- Mux Data — per-second video quality analytics (rebuffering, startup time, quality score)
- Cloudflare Stream uses MP4-to-HLS — automatic adaptive bitrate from any upload
- Bunny Stream supports DRM — Widevine + FairPlay for content protection
- All three support direct browser upload — no server required for file upload
Use Cases
SaaS with video + analytics (like Loom, Wistia) → Mux (Mux Data analytics)
Already on Cloudflare (CDN, Workers, Pages) → Cloudflare Stream
High-volume VOD on a budget → Bunny Stream
DRM content protection → Bunny Stream or Mux
Low-latency live streaming → Mux (sub-3s RTMP)
Simple video embeds with no analytics → Cloudflare Stream
European data residency → Bunny Stream (EU-based)
Mux: Developer-First Video Infrastructure
Mux is the video platform designed for SaaS developers — upload via URL or direct, get playback URLs, thumbnails, GIF previews, captions, and detailed viewer analytics.
Installation
npm install @mux/mux-node # Server SDK
npm install @mux/mux-player-react # Video player component
Upload Video from URL
import Mux from "@mux/mux-node";
const mux = new Mux({
tokenId: process.env.MUX_TOKEN_ID!,
tokenSecret: process.env.MUX_TOKEN_SECRET!,
});
// Create an asset from a URL (Mux downloads and processes it)
async function uploadVideoFromUrl(videoUrl: string): Promise<string> {
const asset = await mux.video.assets.create({
input: [{ url: videoUrl }],
playback_policy: ["public"],
mp4_support: "capped-1080p", // Also generate MP4 for download
encoding_tier: "baseline", // "baseline" | "smart"
video_quality: "plus",
master_access: "none",
});
console.log("Asset ID:", asset.id);
console.log("Playback ID:", asset.playback_ids?.[0]?.id);
return asset.id;
}
// Poll for asset status (or use webhooks)
async function waitForAssetReady(assetId: string): Promise<Mux.Video.Asset> {
let asset = await mux.video.assets.retrieve(assetId);
while (asset.status === "preparing") {
await new Promise((resolve) => setTimeout(resolve, 2000));
asset = await mux.video.assets.retrieve(assetId);
}
return asset;
}
Direct Browser Upload
// Server: Create an upload URL
// app/api/mux/upload/route.ts
export async function POST() {
const upload = await mux.video.uploads.create({
new_asset_settings: {
playback_policy: ["public"],
encoding_tier: "baseline",
},
cors_origin: process.env.NEXT_PUBLIC_APP_URL!,
});
return Response.json({
uploadId: upload.id,
uploadUrl: upload.url, // PUT video bytes directly to this URL
});
}
// Client: Upload file directly to Mux
import * as UpChunk from "@mux/upchunk";
async function uploadVideo(file: File) {
const { uploadUrl } = await fetch("/api/mux/upload", { method: "POST" }).then((r) => r.json());
const upload = UpChunk.createUpload({
endpoint: uploadUrl,
file,
chunkSize: 30720, // 30MB chunks
});
upload.on("progress", (progress) => {
setUploadProgress(progress.detail);
});
upload.on("success", () => {
console.log("Upload complete!");
});
upload.on("error", (error) => {
console.error("Upload error:", error.detail);
});
}
Mux Player (React)
import MuxPlayer from "@mux/mux-player-react";
function VideoPlayer({ playbackId }: { playbackId: string }) {
return (
<MuxPlayer
playbackId={playbackId}
metadata={{
video_title: "My Video",
viewer_user_id: "user-123", // For Mux Data viewer analytics
}}
streamType="on-demand"
autoPlay={false}
muted={false}
poster={`https://image.mux.com/${playbackId}/thumbnail.jpg?time=10`}
style={{ width: "100%", aspectRatio: "16/9" }}
/>
);
}
// Thumbnail URL patterns
// https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg?time=10 (still at 10s)
// https://image.mux.com/{PLAYBACK_ID}/animated.gif?start=10&end=15 (animated GIF)
// https://image.mux.com/{PLAYBACK_ID}/storyboard.vtt (seeking preview sprites)
Webhooks
// app/api/mux/webhook/route.ts
import { headers } from "next/headers";
import Mux from "@mux/mux-node";
const mux = new Mux({ tokenId: process.env.MUX_TOKEN_ID!, tokenSecret: process.env.MUX_TOKEN_SECRET! });
export async function POST(req: Request) {
const body = await req.text();
const headersList = await headers();
const signature = headersList.get("Mux-Signature") ?? "";
// Verify webhook signature
mux.webhooks.verifySignature(body, { "mux-signature": signature }, process.env.MUX_WEBHOOK_SECRET!);
const event = JSON.parse(body);
switch (event.type) {
case "video.asset.ready": {
const asset = event.data;
await db.video.update({
where: { muxAssetId: asset.id },
data: {
status: "ready",
playbackId: asset.playback_ids?.[0]?.id,
duration: asset.duration,
},
});
break;
}
case "video.asset.errored": {
await db.video.update({
where: { muxAssetId: event.data.id },
data: { status: "error" },
});
break;
}
}
return Response.json({ received: true });
}
Signed Playback (Private Videos)
// Create signed playback token for private assets
import jwt from "jsonwebtoken";
function createSignedPlaybackToken(playbackId: string, keyId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
sub: playbackId,
aud: "v", // "v" = video, "t" = thumbnail, "g" = gif, "s" = storyboard
exp: now + 3600, // 1 hour expiry
kid: keyId,
},
privateKey,
{ algorithm: "RS256" }
);
}
// Use in player
const token = createSignedPlaybackToken(playbackId, keyId, privateKey);
// https://stream.mux.com/{PLAYBACK_ID}.m3u8?token={TOKEN}
Cloudflare Stream: Simplest Video API
Cloudflare Stream is the lowest-friction video API — especially for teams already on Cloudflare. Upload a video, embed it, done.
Direct Upload from Browser
// Server: Get a one-time upload URL
async function createStreamUploadUrl(): Promise<{ uploadUrl: string; uid: string }> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/stream/direct_upload`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
maxDurationSeconds: 3600,
requireSignedURLs: false,
meta: { name: "User Upload" },
}),
}
);
const data = await response.json();
return { uploadUrl: data.result.uploadURL, uid: data.result.uid };
}
// Client: Upload directly to Cloudflare
async function uploadToCloudflare(file: File) {
const { uploadUrl, uid } = await fetch("/api/stream/upload", { method: "POST" }).then((r) => r.json());
// Direct TUS upload
const upload = new tus.Upload(file, {
endpoint: uploadUrl,
chunkSize: 50 * 1024 * 1024, // 50MB chunks
onProgress: (uploaded, total) => {
setProgress(Math.round((uploaded / total) * 100));
},
onSuccess: () => {
console.log("Upload complete! Video UID:", uid);
// Poll: https://api.cloudflare.com/.../{uid} until readyToStream: true
},
});
upload.start();
}
Embed Player
// Cloudflare Stream iframe embed
function CloudflarePlayer({ videoId }: { videoId: string }) {
return (
<div style={{ position: "relative", paddingTop: "56.25%" }}>
<iframe
src={`https://customer-${process.env.NEXT_PUBLIC_CF_CUSTOMER_CODE}.cloudflarestream.com/${videoId}/iframe`}
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowFullScreen
style={{ border: "none", position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
/>
</div>
);
}
List and Manage Videos
async function listVideos(accountId: string, apiToken: string) {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/stream`,
{ headers: { Authorization: `Bearer ${apiToken}` } }
);
const data = await response.json();
return data.result; // Array of video objects
}
async function deleteVideo(videoId: string) {
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/stream/${videoId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.CF_API_TOKEN}` },
}
);
}
Bunny Stream: Cost-Efficient Video CDN
Bunny Stream offers competitive pricing with global CDN, video processing, and optional DRM — popular for high-volume content delivery.
Upload and Manage via REST API
const BUNNY_API_KEY = process.env.BUNNY_API_KEY!;
const BUNNY_LIBRARY_ID = process.env.BUNNY_LIBRARY_ID!;
// Create a video entry
async function createBunnyVideo(title: string): Promise<{ videoId: string }> {
const response = await fetch(
`https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos`,
{
method: "POST",
headers: {
AccessKey: BUNNY_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
}
);
const data = await response.json();
return { videoId: data.guid };
}
// Upload video bytes
async function uploadBunnyVideo(videoId: string, videoBuffer: Buffer): Promise<void> {
await fetch(
`https://video.bunnycdn.com/library/${BUNNY_LIBRARY_ID}/videos/${videoId}`,
{
method: "PUT",
headers: {
AccessKey: BUNNY_API_KEY,
"Content-Type": "application/octet-stream",
},
body: videoBuffer,
}
);
}
// Full upload pipeline
async function uploadVideo(title: string, videoPath: string): Promise<string> {
const { videoId } = await createBunnyVideo(title);
const videoBuffer = fs.readFileSync(videoPath);
await uploadBunnyVideo(videoId, videoBuffer);
// Playback URL: https://[hostname].b-cdn.net/{videoId}/playlist.m3u8
return videoId;
}
Embed Player
// Bunny Stream embeds via iframe
function BunnyPlayer({ videoId, libraryId }: { videoId: string; libraryId: string }) {
return (
<div style={{ position: "relative", paddingTop: "56.25%" }}>
<iframe
src={`https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?autoplay=false&loop=false&muted=false&preload=true`}
loading="lazy"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
allowFullScreen
style={{ border: "none", position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
/>
</div>
);
}
Feature Comparison
| Feature | Mux | Cloudflare Stream | Bunny Stream |
|---|---|---|---|
| Adaptive bitrate | ✅ HLS | ✅ HLS | ✅ HLS |
| Direct browser upload | ✅ | ✅ (TUS) | ✅ |
| Custom player | ✅ Mux Player | ✅ Iframe/SDK | ✅ Iframe/SDK |
| Analytics | ✅ Mux Data | ✅ Basic | ✅ Basic |
| DRM | ✅ | ✅ | ✅ Widevine/FairPlay |
| Live streaming | ✅ RTMP | ✅ | ✅ |
| Signed URLs | ✅ | ✅ | ✅ |
| Captions/subtitles | ✅ Auto-generate | ✅ | ✅ |
| Webhook events | ✅ | ✅ | ✅ |
| Price/min stored | $0.015 | $0.005 | $0.0011 |
| Price/GB delivered | $0.045 | $0.001* | $0.0055 |
| Free tier | 100 min/month | 1,000 min/month | ❌ |
*Cloudflare Stream pricing uses minutes viewed model ($1/1k minutes); GB rate is approximate.
When to Use Each
Choose Mux if:
- Building a SaaS product with video as a core feature (education platform, content creation, video hosting)
- Per-viewer analytics (quality score, rebuffering rate, startup time) are important
- Developer experience is a priority — best SDK, best documentation, best DX
- Automatic captions and thumbnail generation are needed
- GIF preview generation for hover-to-preview UX
Choose Cloudflare Stream if:
- You're already a Cloudflare customer (domain, CDN, Workers)
- Simplest possible integration — minimal API surface
- Free 1,000 minutes/month is sufficient for testing or low-volume use
- Cost is the priority for low-volume use cases (cheapest per-minute storage)
Choose Bunny Stream if:
- High-volume video delivery where per-GB CDN cost matters most
- European data residency required (Bunny.net is EU-based)
- DRM is needed without enterprise pricing
- Budget-conscious — Bunny is consistently the cheapest option at scale
Encoding Pipeline and Processing Time
When a video is uploaded to any of these platforms, it goes through an encoding pipeline before it's available for playback. The time this takes and the output quality varies in ways that matter at scale.
Mux encodes with two tiers: Baseline and Smart. Baseline encoding produces standard HLS renditions (360p, 720p, 1080p) and completes in 2-4 minutes for a 10-minute video. Smart encoding — Mux's per-title optimization — analyzes each video's content and produces renditions tuned for that specific video, reducing file size by 20-40% for visually similar quality. A talking-head webinar benefits significantly from Smart encoding; a high-motion sports clip benefits less. Processing with Smart adds 1-2 minutes but typically saves 30-50% on stored bytes and delivered bandwidth. Mux's video.asset.ready webhook notifies your server when the video is playback-ready, so your application doesn't need to poll.
Cloudflare Stream processes uploads through its global network. Encoding typically completes within 1-3 minutes for standard videos. Stream doesn't offer per-title encoding optimization — you get fixed renditions at standard bitrates. The readyToStream: true field on the video object indicates when playback is available. For most use cases this is sufficient; for teams trying to minimize CDN delivery costs, the lack of per-title optimization means Cloudflare Stream will deliver more bytes for visually equivalent quality than Mux Smart encoding.
Bunny Stream's encoding pipeline is the most configurable: you specify which resolutions to generate, enable or disable specific renditions, and set quality presets per video library. This matters for high-volume content pipelines where generating unnecessary renditions wastes storage. Bunny also supports vertical video encoding (9:16 aspect ratio) as an explicit option, which matters for social media content pipelines where portrait format is standard.
Analytics and Viewer Intelligence
The gap in analytics between these platforms is significant enough to be a primary decision factor for products where video quality is a user experience concern.
Mux Data is a separate but deeply integrated product that captures per-second video quality events from every viewer session. Every rebuffering event, quality switch, startup delay, and error is captured and queryable. The Mux Data dashboard shows startup time broken down by geography, rebuffering rate by device type, and error rate by video. For a SaaS company where video quality directly affects user retention, this is the data you need to diagnose and fix playback problems before users complain. Mux Data also works with non-Mux video sources — you can instrument a Cloudflare Stream or Bunny Stream HLS URL with Mux Data's player SDK and get the same quality analytics.
Cloudflare Stream provides basic analytics: total views, bandwidth consumption, and aggregate error rates. The data is available in the Cloudflare Dashboard and the analytics API. There's no per-viewer session tracking, no quality event capture, and no geographic breakdown of buffering performance — just aggregated counts. This is sufficient for simple embed use cases but not for products where playback quality is a tracked product metric.
Bunny Stream offers play counts, bandwidth analytics, and country-level geographic distribution through its dashboard. Like Cloudflare, this is aggregate data without per-session quality tracking. Teams that need quality analytics on top of Bunny Stream typically instrument the player separately — either with Mux Data, Datadog RUM, or a custom analytics layer.
Player Customization and Embedding
Each platform provides an iframe embed, but the degree of player customization available without building a custom implementation varies substantially.
Mux ships @mux/mux-player-react and a mux-player web component — both wrap the underlying HLS.js engine with Mux's own UI shell. The player supports custom thumbnails, chapter markers, caption tracks with styling, and includes Mux Data instrumentation by default. For teams with strong design requirements, CSS custom properties expose most visual parameters — colors, control bar layout, icon styles — without requiring a fork. The player source is open and forkable for deeper customization.
Cloudflare Stream's embed is a plain iframe. There is no React component or web component — you write the iframe HTML directly. For custom player experiences with Cloudflare Stream as the backend, you use HLS.js or video.js directly and point it at the Cloudflare Stream HLS manifest URL. This is technically straightforward but requires building all player controls, keyboard shortcuts, and accessibility from scratch or from a player library.
Bunny Stream similarly provides an iframe embed with a configurable player skin, but no React SDK. Custom player implementations use standard HLS.js against Bunny's manifest URLs. For teams that need pixel-precise control over the video player UI, Cloudflare Stream and Bunny Stream both require more custom development than Mux's player component.
Methodology
Data sourced from official Mux documentation (docs.mux.com), Cloudflare Stream documentation (developers.cloudflare.com/stream), Bunny Stream documentation (docs.bunny.net/reference/api-overview), pricing pages as of February 2026, and community discussions from the Mux Discord and r/webdev.
Related: Fluent FFmpeg vs ffmpeg-wasm vs node-video-lib for self-hosted video processing before uploading, or Remotion vs Motion Canvas vs Revideo for programmatically generating video content to host on these platforms.
See also: Bun vs Vite and LiveKit vs Agora vs 100ms 2026