Build a video management platform in Lovable using Mux or Cloudflare Stream as the transcoding provider. You store video metadata, playlists, and watch history in Supabase, handle upload token generation in an Edge Function, and give users a polished player interface — without building any video processing infrastructure yourself.
What you're building
Video transcoding and CDN delivery is complex infrastructure. Instead of building it yourself, this platform delegates all processing to Mux or Cloudflare Stream. Your Supabase Edge Function requests an upload URL from the provider, the client uploads the raw video file directly to the provider's endpoint (not through your server), and the provider handles transcoding, thumbnail generation, and CDN distribution.
After upload, the provider sends a webhook to your Edge Function when transcoding completes. The webhook handler updates the video row in Supabase with the playback ID and sets status to 'ready'. The frontend subscribes to this update via Supabase Realtime so the video appears in the library without a page refresh.
The player page fetches the video row from Supabase, gets the playback ID, and passes it to the Mux Player React component or a Cloudflare Stream iframe. Watch progress is tracked with a debounced update to a watch_history table, enabling resume playback the next time the user opens the video.
Final result
A fully functional video platform where creators upload raw video files, the provider handles all processing, and viewers watch with a polished player that remembers their progress.
Tech stack
Prerequisites
- Lovable Pro account
- Mux account (mux.com) with an Access Token ID and Secret Key — OR Cloudflare Stream account with API token
- Video provider credentials added to Cloud tab → Secrets
- Supabase project created at supabase.com — free tier works
- At least one test video file (under 200MB) for initial upload testing
Build steps
Define the video metadata schema
The schema stores everything your app needs to know about a video. The actual video bytes live at the provider. Your database stores IDs that let you construct player URLs and API calls.
1Create a video management platform with Supabase. Set up these tables:23- videos: id, owner_id (references auth.users), title (text), description (text), provider (mux|cloudflare), provider_asset_id (text), playback_id (text nullable — set after transcoding), upload_id (text — the provider's upload job ID), status (uploading|processing|ready|error), duration_seconds (numeric nullable), thumbnail_url (text nullable), visibility (public|private|unlisted), tags (text[]), view_count (int default 0), created_at, updated_at4- playlists: id, owner_id (references auth.users), title, description, thumbnail_url, visibility (public|private), created_at5- playlist_videos: id, playlist_id (references playlists), video_id (references videos), position (int), added_at6- watch_history: id, user_id (references auth.users), video_id (references videos), progress_seconds (numeric), completed (bool default false), last_watched_at — unique(user_id, video_id)78RLS:9- videos: SELECT for public videos is unrestricted; SELECT for private videos requires owner_id = auth.uid(); INSERT/UPDATE/DELETE requires owner_id = auth.uid()10- playlists: same visibility-based RLS as videos11- playlist_videos: readable if the parent playlist is accessible; modifiable by playlist owner12- watch_history: users can only read and write their own watch history rows1314Create a storage bucket called 'thumbnails' (public reads, authenticated uploads) for custom video thumbnails.Pro tip: Ask Lovable to add a view_count increment function as a Supabase RPC: CREATE FUNCTION increment_view_count(p_video_id uuid) that does UPDATE videos SET view_count = view_count + 1. Call this RPC when the player reaches 30 seconds of playback, not on page load, to filter out accidental views.
Expected result: All four tables are created with correct RLS policies and TypeScript types. The thumbnails bucket appears in Supabase Storage.
Build the upload Edge Function and upload UI
The upload flow has two parts: the Edge Function requests a direct upload URL from Mux, and the client uploads the file directly to that URL using a standard HTTP PUT. This avoids routing the video file through your Edge Function.
1Create a Supabase Edge Function at supabase/functions/create-video-upload/index.ts.23Receives: { title: string, description: string, visibility: string } in body.4Requires authentication.56For Mux provider:71. POST to https://api.mux.com/video/v1/uploads with Basic auth (MUX_TOKEN_ID:MUX_TOKEN_SECRET from Deno.env)8 Body: { cors_origin: '*', new_asset_settings: { playback_policy: ['public'], mp4_support: 'none' } }92. Response gives: upload.id, upload.url103. Insert into videos: { owner_id, title, description, visibility, provider: 'mux', upload_id: upload.id, status: 'uploading' }114. Return: { video_id: new video row id, upload_url: upload.url }1213For Cloudflare Stream provider (alternative):141. POST to https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/stream?direct_user=true15 Header: Authorization: Bearer {CF_STREAM_TOKEN}16 Header: Tus-Resumable: 1.0.0, Upload-Length: (from client)172. Response Location header = upload URL183. Insert video row and return upload_url1920In the frontend, build a VideoUploadForm component:21- Drag-and-drop zone for video files (accept video/*)22- Fields: title (required), description, visibility Select23- On submit: call create-video-upload Edge Function to get upload_url and video_id24- Upload file to upload_url using fetch with method PUT and the file as body25- Show upload progress using a XMLHttpRequest with progress events and a shadcn/ui Progress bar26- After upload completes, show 'Processing...' state and subscribe to Realtime for that video_idPro tip: For large files, use the Mux direct upload URL with a tus-js-client library for resumable uploads. Ask Lovable to add tus-js-client to the dependencies and replace the fetch PUT with a tus.Upload instance. This lets users resume interrupted uploads without re-uploading from the beginning.
Expected result: Uploading a video file calls the Edge Function, gets the upload URL, uploads the file to Mux, and the video row appears in the database with status 'uploading'. The progress bar shows upload percentage.
Build the webhook handler for transcoding completion
Mux sends a webhook when transcoding finishes. This Edge Function updates the video record with the playback ID and sets status to 'ready'. Supabase Realtime then propagates the change to any subscribed frontend.
1Create a Supabase Edge Function at supabase/functions/mux-webhook/index.ts.23This function handles POST requests from Mux (no authentication required, but verify webhook signature).45Logic:61. Read the mux-signature header from the request.72. Verify the signature using HMAC-SHA256 with MUX_WEBHOOK_SECRET from Deno.env:8 const body = await req.text()9 const expectedSig = 'v1=' + hmacSha256Hex(MUX_WEBHOOK_SECRET, timestamp + '.' + body)10 If signatures don't match, return 401.113. Parse body as JSON: { type, data }124. Handle these event types:13 - 'video.upload.asset_created': update videos SET provider_asset_id = data.asset_id WHERE upload_id = data.upload_id14 - 'video.asset.ready': update videos SET status = 'ready', playback_id = data.playback_ids[0].id, duration_seconds = data.duration, thumbnail_url = 'https://image.mux.com/' + data.playback_ids[0].id + '/thumbnail.jpg' WHERE provider_asset_id = data.id15 - 'video.asset.errored': update videos SET status = 'error' WHERE provider_asset_id = data.id165. Return 200 for all handled events.1718Register this Edge Function URL in the Mux Dashboard under Webhooks. Copy the signing secret from Mux into Cloud tab → Secrets as MUX_WEBHOOK_SECRET.Expected result: Uploading a test video triggers the Mux webhook after 30-90 seconds. The video row in Supabase updates to status 'ready' with a playback_id. The library page shows the video as ready.
Build the video player with watch history
The player page fetches the video metadata, renders the Mux Player, and saves watch progress to the watch_history table every 10 seconds and on pause/tab-close.
1import { useEffect, useRef, useState } from 'react'2import MuxPlayer from '@mux/mux-player-react'3import { supabase } from '@/integrations/supabase/client'4import { Badge } from '@/components/ui/badge'5import { Separator } from '@/components/ui/separator'67type Video = {8 id: string9 title: string10 description: string11 playback_id: string12 duration_seconds: number13 view_count: number14 owner_id: string15 created_at: string16}1718export function VideoPlayer({ videoId }: { videoId: string }) {19 const [video, setVideo] = useState<Video | null>(null)20 const [resumeTime, setResumeTime] = useState<number>(0)21 const playerRef = useRef<HTMLVideoElement | null>(null)22 const progressRef = useRef<number>(0)23 const saveTimer = useRef<ReturnType<typeof setInterval> | null>(null)2425 useEffect(() => {26 supabase.from('videos').select('*').eq('id', videoId).single()27 .then(({ data }) => setVideo(data))28 supabase.from('watch_history').select('progress_seconds').eq('video_id', videoId).single()29 .then(({ data }) => { if (data) setResumeTime(data.progress_seconds) })30 supabase.rpc('increment_view_count', { p_video_id: videoId })31 }, [videoId])3233 const saveProgress = async () => {34 if (progressRef.current < 5) return35 const completed = video ? progressRef.current >= video.duration_seconds * 0.95 : false36 await supabase.from('watch_history').upsert({37 video_id: videoId,38 progress_seconds: progressRef.current,39 completed,40 last_watched_at: new Date().toISOString(),41 }, { onConflict: 'user_id,video_id' })42 }4344 useEffect(() => {45 saveTimer.current = setInterval(saveProgress, 10000)46 window.addEventListener('beforeunload', saveProgress)47 return () => {48 if (saveTimer.current) clearInterval(saveTimer.current)49 window.removeEventListener('beforeunload', saveProgress)50 saveProgress()51 }52 }, [video])5354 if (!video) return null5556 const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`5758 return (59 <div className="max-w-4xl mx-auto space-y-4">60 <MuxPlayer61 playbackId={video.playback_id}62 startTime={resumeTime > 10 ? resumeTime : 0}63 onTimeUpdate={(e) => { progressRef.current = (e.target as HTMLVideoElement).currentTime }}64 onPause={saveProgress}65 style={{ width: '100%', aspectRatio: '16/9' }}66 />67 <div className="space-y-2">68 <h1 className="text-xl font-bold">{video.title}</h1>69 <div className="flex items-center gap-3 text-sm text-muted-foreground">70 <span>{video.view_count.toLocaleString()} views</span>71 <Separator orientation="vertical" className="h-4" />72 <span>{fmt(video.duration_seconds)}</span>73 <Separator orientation="vertical" className="h-4" />74 <span>{new Date(video.created_at).toLocaleDateString()}</span>75 </div>76 {video.description && <p className="text-sm">{video.description}</p>}77 </div>78 </div>79 )80}Expected result: Opening a ready video plays it in the Mux Player. Closing and reopening the page resumes from where you left off. Progress saves every 10 seconds and on pause.
Build the playlist manager
Playlists are ordered sequences of videos. The playlist manager lets users create playlists, add videos from their library, and reorder them with drag handles.
1Build a playlist management page at src/pages/Playlists.tsx.23Requirements:4- List all playlists owned by the user as Cards with thumbnail, title, video count, and visibility Badge.5- 'New Playlist' Button opens a Dialog with title, description, and visibility Select fields.6- Clicking a playlist opens a detail page /playlists/[id] showing the ordered video list.7- On the detail page:8 - Playlist header: thumbnail, title, description, video count, total duration (sum of duration_seconds).9 - Video list as draggable rows using HTML5 drag-and-drop (or ask Lovable to use @dnd-kit/sortable).10 Each row: position number, thumbnail, title, duration, remove Button.11 - Dragging a row to a new position updates the position integers in playlist_videos.12 - 'Add Videos' Button opens a Sheet showing the user's ready videos as checkboxes. Confirming adds them to playlist_videos at the end of the current sequence.13- Playlist player: a 'Play All' Button navigates to /watch/[firstVideoId]?playlist=[playlistId].14 On the player page, when a video ends, auto-advance to the next video in the playlist by querying playlist_videos WHERE playlist_id = X AND position > currentPosition ORDER BY position ASC LIMIT 1.Pro tip: Store position as integers with gaps (10, 20, 30 rather than 1, 2, 3). This allows inserting between two items without renumbering all subsequent rows. Only renumber if the gaps run out (when a new position would need to be between two consecutive integers).
Expected result: Users can create playlists, add videos, reorder them by dragging, and play through the sequence automatically. The position updates persist to Supabase after each drag operation.
Complete code
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'3import { Badge } from '@/components/ui/badge'4import { Card, CardContent } from '@/components/ui/card'5import { Input } from '@/components/ui/input'6import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'7import { Skeleton } from '@/components/ui/skeleton'8import { Link } from 'react-router-dom'910type VideoStatus = 'uploading' | 'processing' | 'ready' | 'error'11type Video = { id: string; title: string; thumbnail_url: string | null; playback_id: string | null; status: VideoStatus; duration_seconds: number | null; view_count: number; created_at: string }1213const STATUS_BADGE: Record<VideoStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {14 ready: { label: 'Ready', variant: 'default' }, processing: { label: 'Processing', variant: 'secondary' },15 uploading: { label: 'Uploading', variant: 'outline' }, error: { label: 'Error', variant: 'destructive' },16}1718const fmtDuration = (s: number | null) => s ? `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}` : '—'1920export function VideoLibrary({ ownerId }: { ownerId: string }) {21 const [videos, setVideos] = useState<Video[]>([])22 const [loading, setLoading] = useState(true)23 const [search, setSearch] = useState('')24 const [statusFilter, setStatusFilter] = useState('all')2526 useEffect(() => {27 setLoading(true)28 let q = supabase.from('videos').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false })29 if (statusFilter !== 'all') q = q.eq('status', statusFilter)30 q.then(({ data }) => { setVideos(data ?? []); setLoading(false) })31 const ch = supabase.channel('video-library')32 .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'videos', filter: `owner_id=eq.${ownerId}` },33 p => setVideos(prev => prev.map(v => v.id === p.new.id ? { ...v, ...p.new as Video } : v)))34 .subscribe()35 return () => { supabase.removeChannel(ch) }36 }, [ownerId, statusFilter])3738 const filtered = videos.filter(v => v.title.toLowerCase().includes(search.toLowerCase()))3940 return (41 <div className="space-y-4">42 <div className="flex gap-3">43 <Input placeholder="Search videos..." value={search} onChange={e => setSearch(e.target.value)} className="max-w-xs" />44 <Select value={statusFilter} onValueChange={setStatusFilter}>45 <SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>46 <SelectContent>47 {['all','ready','processing','error'].map(s => <SelectItem key={s} value={s}>{s === 'all' ? 'All statuses' : s.charAt(0).toUpperCase()+s.slice(1)}</SelectItem>)}48 </SelectContent>49 </Select>50 </div>51 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">52 {loading53 ? Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="aspect-video rounded-lg" />)54 : filtered.map(video => (55 <Link key={video.id} to={video.status === 'ready' ? `/watch/${video.id}` : '#'}>56 <Card className="overflow-hidden hover:ring-2 ring-primary/30 transition-all">57 <div className="relative aspect-video bg-muted">58 {video.thumbnail_url59 ? <img src={video.thumbnail_url} alt={video.title} className="w-full h-full object-cover" />60 : <div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">No thumbnail</div>61 }62 <Badge className="absolute top-2 right-2" variant={STATUS_BADGE[video.status].variant}>{STATUS_BADGE[video.status].label}</Badge>63 {video.duration_seconds && (64 <span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">{fmtDuration(video.duration_seconds)}</span>65 )}66 </div>67 <CardContent className="p-3">68 <p className="text-sm font-medium line-clamp-2">{video.title}</p>69 <p className="text-xs text-muted-foreground mt-1">{video.view_count.toLocaleString()} views</p>70 </CardContent>71 </Card>72 </Link>73 ))74 }75 </div>76 </div>77 )78}Customization ideas
Signed playback URLs for paid content
Mux supports signed playback URLs for private videos. Generate a signed JWT using your Mux signing key in a Supabase Edge Function. Return the signed URL to the frontend only after verifying the user has paid or has an active subscription. The signed URL expires after a configurable duration, preventing link sharing.
Chapter markers and timestamps
Add a video_chapters table (video_id, position_seconds, title). Display chapters as a clickable list below the player. Clicking a chapter seeks the video to that timestamp. Mux Player supports a chapters prop that renders these natively in the player timeline. Store chapter data as part of the video upload metadata form.
Video analytics dashboard
Track detailed view events in a video_events table (video_id, user_id, event_type: play|pause|seek|complete, position_seconds, created_at). Create a /analytics/[videoId] page showing a Recharts area chart of viewer drop-off over the video timeline, total plays, unique viewers, and average watch percentage.
Subtitles and captions
After a video is ready, use the Mux or Cloudflare AI transcription API to generate a VTT subtitle file. Store the subtitle text in Supabase and return it to the Mux Player via the tracks prop. Add a language selector to the player for multi-language caption support.
Video series and courses
Add a series table (id, owner_id, title, description, thumbnail_url) and a series_videos table with position. This is similar to playlists but with enrollment tracking: add a series_enrollments table (user_id, series_id, enrolled_at, completed_at). Show progress as a percentage on the series card based on how many videos the user has completed.
Common pitfalls
Pitfall: Routing the video file upload through a Supabase Edge Function
How to avoid: Always upload video files directly to the provider (Mux or Cloudflare) using the direct upload URL that your Edge Function generates. The client communicates with the provider directly using HTTP PUT. Your Edge Function only handles the token/URL generation, not the file bytes.
Pitfall: Setting the playback_id before the webhook confirms transcoding is complete
How to avoid: Wait for the video.asset.ready webhook event before setting playback_id on the videos row. Keep the video in 'processing' status and show a spinner on the library card until the Realtime UPDATE event fires with status = 'ready'.
Pitfall: Not verifying the webhook signature from Mux
How to avoid: Implement HMAC-SHA256 signature verification using the MUX_WEBHOOK_SECRET. Mux sends a mux-signature header with the signature. Verify it before processing any event.
Pitfall: Saving watch progress on every timeupdate event
How to avoid: Store progress in a ref (not state, to avoid re-renders) and only upsert to Supabase every 10 seconds via a setInterval. Also save on pause, tab close (beforeunload), and component unmount.
Best practices
- Generate video thumbnails automatically using Mux's thumbnail URL pattern: https://image.mux.com/{playback_id}/thumbnail.jpg. Append ?time=5 to get a frame from the 5-second mark. Store this URL in the thumbnail_url column when the webhook fires, so no extra API call is needed later.
- Use a ref rather than state for the video's current playback position. Updating state on every timeupdate event (250ms intervals) causes constant React re-renders. Only update state when you need the UI to reflect the progress, such as for a visible progress indicator.
- Store video upload metadata (title, description, visibility) in the Edge Function that creates the upload URL. This way the video row exists in Supabase immediately and you can show it with 'Uploading' status before the provider receives the file.
- Index the videos table on (owner_id, status, created_at DESC). Library pages always filter by owner and often by status, and always sort by newest first.
- For signed playback URLs, generate them server-side in a dedicated Edge Function that checks auth and subscription status before signing. Never expose your Mux signing private key to the browser.
- Delete videos from Mux or Cloudflare when the user deletes them from your platform. Create a delete-video Edge Function that calls the provider's asset deletion API and then deletes the Supabase row. Orphaned provider assets cost money.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a video platform in Lovable (React + Supabase + Mux). I have a videos table with: id, owner_id, title, playback_id (nullable), status ('uploading'|'processing'|'ready'|'error'), upload_id, duration_seconds. Write a Supabase Edge Function (Deno/TypeScript) called mux-webhook that handles the video.asset.ready and video.upload.asset_created Mux webhook events. Verify the mux-signature header using HMAC-SHA256 with MUX_WEBHOOK_SECRET from Deno.env. On video.asset.ready, update the videos row with playback_id, status='ready', duration_seconds, and thumbnail_url. On video.upload.asset_created, update provider_asset_id.
Add a 'Continue Watching' section to the home page of the video platform. Query the watch_history table for the current user, joining videos, where completed = false and last_watched_at is within the last 30 days. Order by last_watched_at descending, limit to 6 results. Show them as a horizontal scrollable row of Cards with video thumbnail, title, and a thin progress bar at the bottom of the thumbnail showing progress_seconds / duration_seconds. Clicking a card opens the player with the video resuming at the saved position.
In Mux, I want to generate signed playback tokens for private videos. Write a Deno/TypeScript function signMuxPlaybackToken(playbackId: string, secretKeyId: string, secretKeyPem: string): Promise<string> that creates a JWT with: aud='v', sub=playbackId, kid=secretKeyId, exp=now+3600. Use the Web Crypto API (crypto.subtle.importKey and crypto.subtle.sign with RS256) to sign the token with the PEM private key. Return the complete signed JWT string to be appended to the Mux Player URL as ?token=.
Frequently asked questions
Should I use Mux or Cloudflare Stream?
Mux is the default recommendation for most projects. It has an excellent React player component, generous free tier (100 minutes storage, 100 minutes of delivery per month), and detailed analytics. Cloudflare Stream is better if you already use Cloudflare for DNS and CDN, as the billing is simpler ($5 per 1000 minutes stored, $1 per 1000 minutes delivered). Both support direct uploads, webhooks, and signed URLs.
How long does video transcoding take?
Mux and Cloudflare Stream both transcode videos faster than real-time for standard quality settings — a 5-minute video typically takes 1–3 minutes. Your UI should show a 'Processing' badge on the library card and the Realtime subscription will update it to 'Ready' when the webhook fires. Never show an estimated time — just show the spinner until the webhook confirms completion.
Can I restrict who can watch certain videos?
Yes, using two layers: Supabase RLS controls who can query the video metadata (and thus get the playback_id), and Mux signed tokens control who can actually stream the video from Mux's CDN. For truly private content, enable signed playback on the Mux asset and generate short-lived JWT tokens in an Edge Function after verifying the user's access rights in Supabase.
What if a video gets stuck in 'processing' status?
Mux sends a video.asset.errored webhook if transcoding fails. Your webhook handler should update the status to 'error' so users see a clear error state. For videos stuck in 'processing' for more than 30 minutes without a webhook, add a scheduled Edge Function that queries for processing videos older than 30 minutes and calls the Mux API to check their actual status.
Can I add video quality selection to the player?
Mux Player handles adaptive bitrate streaming automatically via HLS — it picks the right quality based on the viewer's network speed. There is no quality selector needed. If you want to give viewers manual control, the Mux Player has a built-in rendition selector that can be enabled via a prop. Cloudflare Stream behaves similarly with automatic ABR.
How do I delete a video and free up provider storage?
Create a delete-video Edge Function that takes a video_id, fetches the provider_asset_id from Supabase, calls DELETE on the Mux or Cloudflare assets API to remove the video from the provider, then deletes the Supabase row (cascading to watch_history and playlist_videos). Never delete the Supabase row without also deleting the provider asset, or you will accumulate storage costs silently.
Can viewers download the video file?
Mux supports MP4 downloads if you enable mp4_support on the asset. When mp4_support is set, Mux generates a static MP4 URL. You can expose a download Button that links to this URL. For content protection, use signed download URLs with short expiry and log download events in a downloads table in Supabase.
Is there help available for production video platform builds?
RapidDev builds production-grade Lovable apps including video platforms with Mux or Cloudflare Stream, subscription paywalls, and custom analytics. Reach out if you need help with your video platform architecture.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation