Clicky

Back to Blog
Web Development May 24, 2026 9 min read

Building a Video Upload Pipeline with Cloudflare Stream and Supabase

How I built a resumable video upload pipeline for mobile apps using Cloudflare Stream and Supabase — with a full cost breakdown, webhook gotchas, and what to upgrade next.

Mobile video upload pipeline — smartphone uploading to Cloudflare Stream for transcoding, then to Supabase for metadata, auth, and state

A parent records a 20-second highlight on an iPhone at the sideline. By the time they walk to their car, it should already be in the app, transcoded, and playable. That’s the product promise.

Delivering it forced one question: do I want to own video infrastructure at all? For a standalone MVP uploader for mobile sideline highlights, the answer was no — I needed reliable uploads and fast shipping, not a custom transcode stack.

Cloudflare Stream and Supabase split the work cleanly. Stream handles upload, transcoding, thumbnails, and playback. Supabase handles metadata, auth, Edge Functions, and database rules. The browser never sends video bytes through my backend — only metadata and orchestration touch Supabase.

TL;DR

Stack: React · TUS · Cloudflare Stream · Supabase Edge Functions · Postgres · Supabase Auth · Stream iframe playback


Architecture

Two services, one pipeline. Cloudflare Stream owns everything video-specific; Supabase owns product rules — who can upload, what status a clip is in, who can see it once published.

Video upload pipeline architecture — Browser to Supabase for metadata, Browser to Cloudflare Stream via TUS for the file, Cloudflare Stream back to Supabase via webhook after transcoding

Three lanes: browser to Supabase for metadata, browser to Stream for the file via TUS, Stream back to Supabase via webhook when processing finishes.

The sections below cover the upload flow, webhook handling, why Stream over alternatives, what Supabase owns, cost, and what to change when you outgrow the prototype.

Lifecycle and state

Every clip follows a strict status path:

Clip state machine — uploading to uploaded to transcoded to published, with failed and rejected as terminal states

Statuses move in order: uploadinguploadedtranscodedpublished, with failed and rejected as terminal states. Clips can’t skip steps — a row can’t go public until upload and transcoding are both confirmed. Without that, you get database records that aren’t watchable yet, which breaks moderation and UI state.

In the prototype, parents see simple upload progress; coaches only see clips at published.

Upload

The upload path has two jobs: validate before wasting bandwidth, and keep the backend out of the file transfer.

Step 1 — validate in the browser. Check file size, that the file is readable as video, and duration. For this workflow: 5–30 seconds, 200 MB cap. Invalid files fail before the user waits through an upload.

Step 2 — ask Supabase for a TUS URL. The client calls a Supabase Edge Function, which checks a kill switch, enforces a monthly upload cap, requests a TUS URL from Stream, inserts a clips row at uploading, and returns the URL. Supabase controls whether an upload is allowed; it never receives the file.

Step 3 — upload to Stream with TUS. The client uses tus-js-client (or Cloudflare’s first-party direct-upload SDKs). Uploads are chunked with retry and backoff — essential for sideline Wi-Fi. If TUS fails after retries, the row stays at uploading; no webhook fires for a clip that never reached Stream.

Webhooks

Transcoding is asynchronous. Upload success is not playback readiness.

When Stream finishes processing, it POSTs to a Supabase Edge Function. That handler verifies the Webhook-Signature (HMAC-SHA256) before touching Postgres — without verification, anyone who knows the URL can spoof status updates. You can scope events with Webhook Subscriptions instead of relying on a single account-wide hook.

readyToStream is the field that matters. Processing can complete before the clip is actually playable. Only promote a row from uploaded to transcoded when readyToStream is true. Reject unknown video IDs (Stream sends account-wide events), and treat duplicate deliveries as idempotent.

Webhooks beat polling: no “is it ready yet?” loop, no background worker, one row update per event.

Gotchas worth designing for upfront:

Playback and deletion

Once transcoding works, playback is thin: thumbnails and a Stream iframe embed. No custom player, format detection, or bitrate logic. The prototype uses public playback — a deliberate shortcut.

Deletion must hit both systems: remove the Postgres row and call Stream’s delete API. Otherwise you get orphaned assets (ongoing storage cost) or stale RLS-visible references. Soft-delete first if you want a recovery window.

Why Stream

I looked at Mux, Cloudinary, and AWS before landing here.

Mux was the closest call — strong DX, solid direct uploads. I wanted predictable per-minute billing I could cap with upload limits at prototype scale; Stream fit that mental model. Mux’s playback analytics and API depth weren’t needed for ingest-and-play.

Cloudinary targets asset-heavy workflows — transforms, crops, DAM. This uploader only needed ingest, transcode, play.

AWS would mean MediaConvert, S3, CloudFront, and job orchestration at minimum — back to owning video infrastructure.

Stream vs R2 was the harder call within Cloudflare:

Cloudflare Stream vs R2 — Stream wins on managed transcoding, browser compatibility, adaptive streaming, and thumbnails at a higher cost; R2 wins on price but leaves everything else to you

R2 is cheap storage with no egress fees. The problem was never storage — it was playback. iPhones upload HEVC; other devices send H.264 or different containers. Serve raw files from R2 and you own compatibility, transcoding, and every “works on my phone, not theirs” ticket.

I’d seen HEVC from iPhones fail silently on mobile Safari before — no error, just a broken player. Stream removes that: upload whatever the device produced; get web-friendly formats, adaptive streaming, thumbnails. Treat it as a media delivery problem, not a file storage problem.

What Supabase owns

Supabase is the control layer, not the video platform:

Kill switch, monthly upload cap, and webhook processing all live here. Stream never needs to know your product rules; Supabase never needs the video bytes.

What it costs

Assumptions: 50 uploads/day, 30-second clips, ~10 views per clip.

Billable VectorCloudflare Stream Pricing StructureMVP Cost Estimate (At Prototype Scale)
Video Storage$5.00 per 1,000 minutes stored / month$3.75 / mo (~750 total minutes accumulated)
Video Delivery$1.00 per 1,000 minutes delivered$7.50 / mo (~7,500 playback minutes used)
Supabase ComputeFree Tier (Up to 500k Edge invocations)$0.00 (Well within standard operational limits)
Total Pipeline Cost~$11.25 / month

Directional estimates only — delivery billing includes buffering and segment preloading. Supabase stays on the free tier at this scale; Pro ($25/mo) dominates only when you outgrow limits.

Where costs spike: delivery. Storage is predictable; a viral clip burns delivery minutes fast. The kill switch, monthly cap, and prepaid storage limit are cheap guardrails while usage is still unknown.

Beyond the prototype

Good fit if you:

Poor fit if you:

First upgrades when you outgrow prototype:


Building a video upload feature for your MVP? If you want a second opinion on your stack before you build it, let’s talk.

Tags:

Want help applying this to your product?

If this post matches what you are building, I can help you execute it with clear scope and delivery.