Stream Video with Cloudflare Stream in 2026
How to Stream Video with Cloudflare Stream
Cloudflare Stream handles video encoding, storage, and adaptive bitrate delivery. Upload a video, get an embed code. No transcoding pipelines, no CDN configuration, no player headaches. This guide covers uploads, playback, access control, and live streaming.
TL;DR
- Cloudflare Stream handles encoding, CDN delivery, and adaptive bitrate automatically — no S3 + Lambda + CloudFront pipeline to manage
- Direct creator uploads send video files straight from the browser to Cloudflare, bypassing your server entirely
- Signed URLs (RS256 JWT) provide access control for private content without proxying video traffic through your servers
- Live streaming via RTMPS is production-ready and integrates with OBS; recordings are automatic
- Pricing is usage-based ($5/1,000 minutes stored + $1/1,000 minutes delivered) — model your costs before committing
Why Cloudflare Stream
Video delivery is one of the most infrastructure-intensive challenges in web development. A naive implementation stores raw video files in S3, which cannot stream — it can only serve the file as a download. A better implementation transcodes video into multiple quality levels and delivers via HLS (HTTP Live Streaming) with an adaptive bitrate player. A production implementation of this from scratch involves: S3 or R2 for storage, AWS MediaConvert or similar for transcoding (which requires job management and output configuration), CloudFront or similar CDN for delivery, a video player library (video.js, HLS.js, Shaka Player) with HLS configuration, and operational overhead monitoring all of the above. Cloudflare Stream replaces this entire stack.
What Stream does for you: transcodes uploaded video into multiple quality renditions (360p, 480p, 720p, 1080p), delivers via Cloudflare's global CDN (300+ data centers), generates an adaptive HLS manifest that the player uses to select quality based on available bandwidth, provides an embeddable iframe player, generates thumbnail images and animated preview GIFs, and handles live streaming via RTMPS.
When Stream is the right choice: for products where video is a feature (course platforms, user-generated content, video documentation) rather than the core product. If video is central to your business and you need fine-grained control over encoding quality, chapter markers, subtitle handling, and player customization beyond what Stream's default player offers, services like Mux may be a better fit. For most teams building video as a feature, Stream's simplicity-to-capability ratio is exceptional.
What You'll Build
- Video upload (direct and via URL)
- Embeddable player with adaptive streaming
- Signed URLs for private video access
- Live streaming with RTMP
- Thumbnail generation
Prerequisites: Cloudflare account with Stream enabled ($5 minimum, pay-per-use).
1. Setup
Get API Credentials
- Cloudflare Dashboard → Stream
- Get your Account ID from the sidebar
- Create an API token with Stream permissions
CLOUDFLARE_ACCOUNT_ID=your_account_id
CLOUDFLARE_API_TOKEN=your_api_token
API Helper
// lib/cloudflare-stream.ts
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
const API_TOKEN = process.env.CLOUDFLARE_API_TOKEN!;
const BASE_URL = `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/stream`;
async function streamApi(path: string, options?: RequestInit) {
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${API_TOKEN}`,
...options?.headers,
},
});
return res.json();
}
2. Upload Videos
When a user submits a video, you have two paths: server-side upload (your server fetches or receives the file, then sends it to Cloudflare) and direct creator upload (your server generates a pre-signed upload URL, the client uploads directly to Cloudflare). For files larger than a few megabytes, direct creator upload is strongly preferred — the video never touches your server, so your bandwidth costs are zero and the upload saturates the user's full connection speed rather than being bottlenecked by your server.
Direct Upload (from server)
export async function uploadFromUrl(videoUrl: string, name: string) {
const data = await streamApi('/copy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: videoUrl,
meta: { name },
}),
});
return {
uid: data.result.uid,
playbackUrl: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${data.result.uid}/manifest/video.m3u8`,
embedUrl: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${data.result.uid}/iframe`,
};
}
Direct Creator Upload (browser-to-Cloudflare)
// 1. Server: Create upload URL
export async function createUploadUrl() {
const data = await streamApi('/direct_upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
maxDurationSeconds: 3600,
requireSignedURLs: false,
}),
});
return {
uploadUrl: data.result.uploadURL,
uid: data.result.uid,
};
}
// 2. Client: Upload directly to Cloudflare
async function uploadVideo(file: File) {
const { uploadUrl, uid } = await fetch('/api/stream/upload', {
method: 'POST',
}).then(r => r.json());
const formData = new FormData();
formData.append('file', file);
await fetch(uploadUrl, {
method: 'POST',
body: formData,
});
return uid;
}
Upload Strategy: Direct vs Server-Side
The choice between server-side and direct creator uploads is a performance and cost decision with security implications. Understanding the model prevents common mistakes.
Server-side upload is appropriate for machine-generated video content, migration of existing video libraries, or cases where the video source is a URL rather than a user-uploaded file. Your server fetches or receives the video and POSTs it to Cloudflare. This is simple but expensive for large files: your server receives the upload bandwidth cost twice (once from the user, once to Cloudflare), your server needs to handle large file buffers, and upload speed is limited by your server's outbound bandwidth.
Direct creator upload is correct for user-uploaded video in almost all cases. Your server makes a lightweight API call to create an upload URL, returns that URL to the client, and the client uploads directly. The upload URL is pre-signed and single-use — it can only be used to upload to the specific video UID you created. The security model is solid: the client cannot upload to arbitrary Cloudflare accounts or overwrite existing videos, they can only use the URL you generated.
Upload failures and resumption: Video uploads can fail mid-way (network interruption, browser tab closed). Cloudflare Stream supports TUS (resumable uploads protocol) for large files. The TUS client library handles checkpointing — if the upload fails, resuming from the last checkpoint avoids re-uploading the entire file:
import * as tus from 'tus-js-client';
async function resumableUpload(file: File, uploadUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: uploadUrl,
retryDelays: [0, 3000, 5000, 10000, 20000],
chunkSize: 50 * 1024 * 1024, // 50MB chunks
metadata: {
filename: file.name,
filetype: file.type,
},
onError: reject,
onSuccess: resolve,
onProgress: (bytesUploaded, bytesTotal) => {
const percent = Math.round((bytesUploaded / bytesTotal) * 100);
console.log(`Upload progress: ${percent}%`);
},
});
upload.findPreviousUploads().then(previousUploads => {
if (previousUploads.length > 0) {
upload.resumeFromPreviousUpload(previousUploads[0]);
}
upload.start();
});
});
}
After upload completes, the video enters encoding. Poll the video status until it reads "readyToStream": true before displaying the player — attempting to load a video that is still encoding will show a player error.
3. Embed Player
iframe Embed
<iframe
src="https://customer-ACCOUNT_ID.cloudflarestream.com/VIDEO_UID/iframe"
style="border: none; width: 100%; aspect-ratio: 16/9;"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
allowfullscreen
></iframe>
Stream Player Web Component
<script src="https://embed.cloudflarestream.com/embed/sdk.latest.js"></script>
<stream src="VIDEO_UID" controls autoplay muted></stream>
React Component
'use client';
export function VideoPlayer({ uid, title }: { uid: string; title?: string }) {
return (
<div style={{ position: 'relative', paddingTop: '56.25%' }}>
<iframe
src={`https://customer-${process.env.NEXT_PUBLIC_CF_ACCOUNT_ID}.cloudflarestream.com/${uid}/iframe`}
title={title}
style={{
border: 'none',
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}}
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
</div>
);
}
Adaptive Bitrate Streaming
Cloudflare Stream delivers video using HLS (HTTP Live Streaming), the standard for adaptive bitrate video delivery on the web and mobile. Understanding how HLS works helps you design better video experiences and debug playback issues.
HLS works by dividing video into small segments (typically 2-6 seconds each) and generating a manifest file (.m3u8) that lists all available quality renditions and the segment URLs for each. The player downloads the manifest first, then selects a quality level based on the user's current network bandwidth. As playback continues, the player continuously measures download speed and switches quality renditions up or down to maintain smooth playback.
Cloudflare Stream automatically encodes uploaded video into multiple renditions: 360p, 480p, 720p, and 1080p (for source videos at those resolutions or higher). The adaptive bitrate selection happens in the player — Cloudflare's embedded player and the Stream web component both implement adaptive bitrate selection. If you use a third-party player (HLS.js, Shaka Player), point it at the .m3u8 manifest URL and the library handles the rest.
Why this matters for mobile: mobile users frequently experience bandwidth fluctuations — moving between LTE and WiFi, entering buildings, riding in vehicles. Adaptive bitrate ensures that a bandwidth drop causes a quality reduction rather than a buffering stall. Without adaptive bitrate (e.g., serving a single MP4 file), a user on a slow connection either downloads a huge file slowly or experiences constant buffering. The user experience difference is dramatic.
The key mistake to avoid is using the MP4 download URL for video embeds. Stream provides both an MP4 download URL and an HLS manifest URL. The MP4 URL delivers the video as a single file — no adaptive bitrate, no range requests, no performance optimization. Always use the HLS manifest URL (ending in /manifest/video.m3u8) or the iframe embed URL for video playback.
4. Signed URLs (Private Videos)
Create Signing Keys
const keys = await streamApi('/keys', { method: 'POST' });
const signingKey = keys.result;
// Save signingKey.id and signingKey.pem securely
Generate Signed Token
import jwt from 'jsonwebtoken';
export function createSignedUrl(videoUid: string, expiresInHours: number = 1) {
const token = jwt.sign(
{
sub: videoUid,
kid: process.env.CF_STREAM_KEY_ID,
exp: Math.floor(Date.now() / 1000) + expiresInHours * 3600,
accessRules: [
{ type: 'any', action: 'allow' },
],
},
process.env.CF_STREAM_SIGNING_KEY!,
{ algorithm: 'RS256' }
);
return `https://customer-${process.env.CLOUDFLARE_ACCOUNT_ID}.cloudflarestream.com/${token}/manifest/video.m3u8`;
}
Access Control Architecture
The decision between public and private video affects your entire architecture. Public videos (no signed URL required) are simpler and have better CDN cacheability — the player can be embedded anywhere and the video URL can be shared. Private videos require server-side token generation for every playback session, but prevent unauthorized access.
The requireSignedURLs: true flag on the video upload (or via API update) tells Cloudflare to reject requests that don't include a valid signed token. The token is a JWT signed with your Cloudflare Stream signing key (a per-account RSA key pair). Cloudflare's edge validates the JWT at the CDN layer — no traffic reaches your origin to perform authorization checks.
Access rules embedded in the JWT enable fine-grained control beyond simple allow/deny:
// Restrict to specific IP addresses (enterprise use case)
accessRules: [
{ type: 'ip.src', action: 'allow', ip: ['192.168.1.0/24'] },
{ type: 'any', action: 'block' },
]
// Time-limited download prevention
accessRules: [
{ type: 'any', action: 'allow', country: ['US', 'CA', 'GB'] },
{ type: 'any', action: 'block' },
]
JWT signing key rotation should be part of your security practice. Cloudflare allows you to have multiple active signing keys simultaneously. Rotation process: create a new signing key, update your application to use it, wait for existing tokens (which are short-lived) to expire, then delete the old key. This rotation can be done without any service interruption or forced re-authentication.
For a private video platform (course content, premium video), the typical architecture is: user authenticates to your API, your API checks authorization (is this user subscribed? do they have access to this course?), generates a signed token with a 2-hour expiry, returns the token URL to the client. The video is streamed directly from Cloudflare's edge — your server is only involved in the authorization check, not the video delivery.
5. Live Streaming
Create Live Input
export async function createLiveStream() {
const data = await streamApi('/live_inputs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
meta: { name: 'My Live Stream' },
recording: { mode: 'automatic' },
}),
});
return {
uid: data.result.uid,
rtmpUrl: data.result.rtmps.url,
rtmpKey: data.result.rtmps.streamKey,
srtUrl: data.result.srt.url,
playbackUrl: `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${data.result.uid}/manifest/video.m3u8`,
};
}
Stream from OBS
- Server: Use the RTMPS URL from the API response
- Stream Key: Use the stream key from the API response
6. Thumbnails and Previews
// Static thumbnail at a specific time
const thumbnailUrl = `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${uid}/thumbnails/thumbnail.jpg?time=10s&width=640`;
// Animated GIF
const gifUrl = `https://customer-${ACCOUNT_ID}.cloudflarestream.com/${uid}/thumbnails/thumbnail.gif?start=5s&end=10s&width=320`;
7. Video Analytics
// Get analytics via GraphQL
const analytics = await fetch(
`https://api.cloudflare.com/client/v4/graphql`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: `
query {
viewer {
accounts(filter: { accountTag: "${ACCOUNT_ID}" }) {
streamMinutesViewedAdaptiveGroups(
filter: { date_gt: "2026-03-01" }
limit: 100
) {
sum { minutesViewed }
dimensions { uid }
}
}
}
}
`,
}),
}
).then(r => r.json());
Cloudflare Stream vs Mux vs api.video
Choosing a video API is primarily a tradeoff between simplicity, cost, and advanced features. All three services handle encoding and delivery — the differences are in analytics, developer experience, ecosystem integration, and pricing model.
Cloudflare Stream wins on: CDN performance (Cloudflare's network is in 300+ cities globally — video delivery latency is exceptional), Workers integration (if you use Cloudflare Workers for your API, Stream integrates trivially with shared R2 storage and edge routing), and pricing predictability (the per-minute pricing scales proportionally with usage). Stream's weaknesses: the analytics dashboard is basic compared to Mux, the player customization options are limited (no native chapter markers, subtitle handling requires external implementation), and the API documentation, while good, is less developer-experience-focused than Mux's.
Mux wins on: analytics (Mux Data provides per-viewer quality-of-experience metrics — buffering ratio, startup time, error rate per viewer session — that Stream does not), player features (thumbnail selection API, storyboard previews, subtitle management), and developer experience (the Mux documentation and SDKs are among the best in the API industry). Mux's weaknesses: pricing can escalate quickly at scale (encoding and storage fees on top of delivery), and their network is smaller than Cloudflare's.
api.video wins on: simplicity for straightforward use cases and competitive pricing for upload-heavy use cases (encoding is cheaper). It is less feature-rich than Mux and has a smaller network than Cloudflare.
When to choose each:
- Cloudflare Stream: already using Cloudflare infrastructure, need global delivery performance, building in Workers ecosystem
- Mux: need per-viewer quality analytics, building a sophisticated video product, quality of experience monitoring is a product requirement
- api.video: simplest integration, cost-sensitive, not yet at scale where differences matter
For a comprehensive view of video API options, browse the API directory on APIScout.
Pricing
| Component | Cost |
|---|---|
| Storage | $5/1,000 minutes stored |
| Delivery | $1/1,000 minutes viewed |
| Live streaming | $0.75/1,000 minutes input |
| Minimum | $5/month |
Example: 100 videos × 5 min each (500 min stored) + 10,000 views × 5 min each (50,000 min delivered):
- Storage: $2.50
- Delivery: $50.00
- Total: $52.50/month
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Not using direct creator uploads | Videos route through your server — slow | Use direct upload URLs |
Missing requireSignedURLs for private content | Anyone with URL can view | Enable signed URLs |
| Not handling encoding status | Player shows error on unfinished videos | Poll status until "ready" |
| Using mp4 download links for streaming | No adaptive bitrate, poor mobile experience | Use HLS manifest URL |
| Hardcoding account ID in client | Works but messy | Use environment variable |
Production Deployment Checklist
Deploying Cloudflare Stream to production requires more than working code. A reliable video feature needs:
Environment variables: Never hardcode credentials. You need CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN (with Stream write permissions), CF_STREAM_KEY_ID, and CF_STREAM_SIGNING_KEY (the RSA private key in PEM format). In Next.js, signing key and account token are server-only — never expose to the browser. The account ID can be public (used in iframe URLs) but is cleaner as an environment variable.
Upload error handling: Handle the three failure modes: upload URL generation failure (your API call to Cloudflare failed — retry with backoff), upload failure during transfer (TUS handles resumption automatically if you use the tus-js-client), and encoding failure (video uploaded successfully but Cloudflare cannot encode it — query the video status, check state === 'error' and the error message).
Status polling until ready: After upload completes, the video is not immediately ready for playback. Poll the video status endpoint until readyToStream is true:
async function waitForVideoReady(uid: string, maxWaitMs = 300_000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
const data = await streamApi(`/${uid}`);
if (data.result.readyToStream) return;
if (data.result.state === 'error') {
throw new Error(`Video encoding failed: ${data.result.status?.errorReasonText}`);
}
await new Promise(r => setTimeout(r, 5000)); // Poll every 5 seconds
}
throw new Error('Video encoding timed out');
}
Player accessibility: The Cloudflare Stream iframe player supports keyboard navigation and screen reader descriptions. Set the title attribute on the iframe to describe the video content for screen readers. For subtitle support (WCAG AA requires captions for pre-recorded video), upload a WebVTT caption file to Stream and enable it via the API.
GDPR and video analytics: Cloudflare Stream analytics collect per-viewer data (IP address, country, device type) that may be subject to GDPR if you have EU users. Review Cloudflare's data processing agreement and DPA to ensure compliance. Stream analytics do not expose individual viewer identities — data is aggregated — but IP addresses are processed, which is sufficient to trigger GDPR applicability.
For more on building robust API integrations, see our guides on API error handling and how to monitor API performance.
Conclusion
Cloudflare Stream is an excellent choice for teams that want production-quality video delivery without the operational overhead of managing a transcoding pipeline. The direct creator upload pattern, signed URL access control, and HLS adaptive delivery cover the requirements of most video features. The per-minute pricing is predictable and scales linearly.
The limitations to be aware of: limited player customization, basic analytics, and no per-viewer quality-of-experience data. For products where video is central and those capabilities matter, Mux is worth the higher cost. For most products where video is a supporting feature, Cloudflare Stream delivers exceptional value.
Related: Add Video Streaming with Mux in 2026, Cloudflare Stream vs api.video, Mux vs Cloudflare Stream (2026)