# How to Build a Video Streaming Backend with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
Create a video management platform with Supabase. Set up these tables:

- 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_at
- playlists: id, owner_id (references auth.users), title, description, thumbnail_url, visibility (public|private), created_at
- playlist_videos: id, playlist_id (references playlists), video_id (references videos), position (int), added_at
- 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)

RLS:
- videos: SELECT for public videos is unrestricted; SELECT for private videos requires owner_id = auth.uid(); INSERT/UPDATE/DELETE requires owner_id = auth.uid()
- playlists: same visibility-based RLS as videos
- playlist_videos: readable if the parent playlist is accessible; modifiable by playlist owner
- watch_history: users can only read and write their own watch history rows

Create 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.

### 2. 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.

```
Create a Supabase Edge Function at supabase/functions/create-video-upload/index.ts.

Receives: { title: string, description: string, visibility: string } in body.
Requires authentication.

For Mux provider:
1. POST to https://api.mux.com/video/v1/uploads with Basic auth (MUX_TOKEN_ID:MUX_TOKEN_SECRET from Deno.env)
   Body: { cors_origin: '*', new_asset_settings: { playback_policy: ['public'], mp4_support: 'none' } }
2. Response gives: upload.id, upload.url
3. Insert into videos: { owner_id, title, description, visibility, provider: 'mux', upload_id: upload.id, status: 'uploading' }
4. Return: { video_id: new video row id, upload_url: upload.url }

For Cloudflare Stream provider (alternative):
1. POST to https://api.cloudflare.com/client/v4/accounts/{CF_ACCOUNT_ID}/stream?direct_user=true
   Header: Authorization: Bearer {CF_STREAM_TOKEN}
   Header: Tus-Resumable: 1.0.0, Upload-Length: (from client)
2. Response Location header = upload URL
3. Insert video row and return upload_url

In the frontend, build a VideoUploadForm component:
- Drag-and-drop zone for video files (accept video/*)
- Fields: title (required), description, visibility Select
- On submit: call create-video-upload Edge Function to get upload_url and video_id
- Upload file to upload_url using fetch with method PUT and the file as body
- Show upload progress using a XMLHttpRequest with progress events and a shadcn/ui Progress bar
- After upload completes, show 'Processing...' state and subscribe to Realtime for that video_id
```

> Pro 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.

### 3. 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.

```
Create a Supabase Edge Function at supabase/functions/mux-webhook/index.ts.

This function handles POST requests from Mux (no authentication required, but verify webhook signature).

Logic:
1. Read the mux-signature header from the request.
2. Verify the signature using HMAC-SHA256 with MUX_WEBHOOK_SECRET from Deno.env:
   const body = await req.text()
   const expectedSig = 'v1=' + hmacSha256Hex(MUX_WEBHOOK_SECRET, timestamp + '.' + body)
   If signatures don't match, return 401.
3. Parse body as JSON: { type, data }
4. Handle these event types:
   - 'video.upload.asset_created': update videos SET provider_asset_id = data.asset_id WHERE upload_id = data.upload_id
   - '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.id
   - 'video.asset.errored': update videos SET status = 'error' WHERE provider_asset_id = data.id
5. Return 200 for all handled events.

Register 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.

### 4. 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.

```
import { useEffect, useRef, useState } from 'react'
import MuxPlayer from '@mux/mux-player-react'
import { supabase } from '@/integrations/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'

type Video = {
  id: string
  title: string
  description: string
  playback_id: string
  duration_seconds: number
  view_count: number
  owner_id: string
  created_at: string
}

export function VideoPlayer({ videoId }: { videoId: string }) {
  const [video, setVideo] = useState<Video | null>(null)
  const [resumeTime, setResumeTime] = useState<number>(0)
  const playerRef = useRef<HTMLVideoElement | null>(null)
  const progressRef = useRef<number>(0)
  const saveTimer = useRef<ReturnType<typeof setInterval> | null>(null)

  useEffect(() => {
    supabase.from('videos').select('*').eq('id', videoId).single()
      .then(({ data }) => setVideo(data))
    supabase.from('watch_history').select('progress_seconds').eq('video_id', videoId).single()
      .then(({ data }) => { if (data) setResumeTime(data.progress_seconds) })
    supabase.rpc('increment_view_count', { p_video_id: videoId })
  }, [videoId])

  const saveProgress = async () => {
    if (progressRef.current < 5) return
    const completed = video ? progressRef.current >= video.duration_seconds * 0.95 : false
    await supabase.from('watch_history').upsert({
      video_id: videoId,
      progress_seconds: progressRef.current,
      completed,
      last_watched_at: new Date().toISOString(),
    }, { onConflict: 'user_id,video_id' })
  }

  useEffect(() => {
    saveTimer.current = setInterval(saveProgress, 10000)
    window.addEventListener('beforeunload', saveProgress)
    return () => {
      if (saveTimer.current) clearInterval(saveTimer.current)
      window.removeEventListener('beforeunload', saveProgress)
      saveProgress()
    }
  }, [video])

  if (!video) return null

  const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`

  return (
    <div className="max-w-4xl mx-auto space-y-4">
      <MuxPlayer
        playbackId={video.playback_id}
        startTime={resumeTime > 10 ? resumeTime : 0}
        onTimeUpdate={(e) => { progressRef.current = (e.target as HTMLVideoElement).currentTime }}
        onPause={saveProgress}
        style={{ width: '100%', aspectRatio: '16/9' }}
      />
      <div className="space-y-2">
        <h1 className="text-xl font-bold">{video.title}</h1>
        <div className="flex items-center gap-3 text-sm text-muted-foreground">
          <span>{video.view_count.toLocaleString()} views</span>
          <Separator orientation="vertical" className="h-4" />
          <span>{fmt(video.duration_seconds)}</span>
          <Separator orientation="vertical" className="h-4" />
          <span>{new Date(video.created_at).toLocaleDateString()}</span>
        </div>
        {video.description && <p className="text-sm">{video.description}</p>}
      </div>
    </div>
  )
}
```

**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.

### 5. 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.

```
Build a playlist management page at src/pages/Playlists.tsx.

Requirements:
- List all playlists owned by the user as Cards with thumbnail, title, video count, and visibility Badge.
- 'New Playlist' Button opens a Dialog with title, description, and visibility Select fields.
- Clicking a playlist opens a detail page /playlists/[id] showing the ordered video list.
- On the detail page:
  - Playlist header: thumbnail, title, description, video count, total duration (sum of duration_seconds).
  - Video list as draggable rows using HTML5 drag-and-drop (or ask Lovable to use @dnd-kit/sortable).
    Each row: position number, thumbnail, title, duration, remove Button.
  - Dragging a row to a new position updates the position integers in playlist_videos.
  - '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.
- Playlist player: a 'Play All' Button navigates to /watch/[firstVideoId]?playlist=[playlistId].
  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 example

File: `src/components/video/VideoLibrary.tsx`

```typescript
import { useEffect, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Link } from 'react-router-dom'

type VideoStatus = 'uploading' | 'processing' | 'ready' | 'error'
type 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 }

const STATUS_BADGE: Record<VideoStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
  ready: { label: 'Ready', variant: 'default' }, processing: { label: 'Processing', variant: 'secondary' },
  uploading: { label: 'Uploading', variant: 'outline' }, error: { label: 'Error', variant: 'destructive' },
}

const fmtDuration = (s: number | null) => s ? `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}` : '—'

export function VideoLibrary({ ownerId }: { ownerId: string }) {
  const [videos, setVideos] = useState<Video[]>([])
  const [loading, setLoading] = useState(true)
  const [search, setSearch] = useState('')
  const [statusFilter, setStatusFilter] = useState('all')

  useEffect(() => {
    setLoading(true)
    let q = supabase.from('videos').select('*').eq('owner_id', ownerId).order('created_at', { ascending: false })
    if (statusFilter !== 'all') q = q.eq('status', statusFilter)
    q.then(({ data }) => { setVideos(data ?? []); setLoading(false) })
    const ch = supabase.channel('video-library')
      .on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'videos', filter: `owner_id=eq.${ownerId}` },
        p => setVideos(prev => prev.map(v => v.id === p.new.id ? { ...v, ...p.new as Video } : v)))
      .subscribe()
    return () => { supabase.removeChannel(ch) }
  }, [ownerId, statusFilter])

  const filtered = videos.filter(v => v.title.toLowerCase().includes(search.toLowerCase()))

  return (
    <div className="space-y-4">
      <div className="flex gap-3">
        <Input placeholder="Search videos..." value={search} onChange={e => setSearch(e.target.value)} className="max-w-xs" />
        <Select value={statusFilter} onValueChange={setStatusFilter}>
          <SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
          <SelectContent>
            {['all','ready','processing','error'].map(s => <SelectItem key={s} value={s}>{s === 'all' ? 'All statuses' : s.charAt(0).toUpperCase()+s.slice(1)}</SelectItem>)}
          </SelectContent>
        </Select>
      </div>
      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
        {loading
          ? Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="aspect-video rounded-lg" />)
          : filtered.map(video => (
            <Link key={video.id} to={video.status === 'ready' ? `/watch/${video.id}` : '#'}>
              <Card className="overflow-hidden hover:ring-2 ring-primary/30 transition-all">
                <div className="relative aspect-video bg-muted">
                  {video.thumbnail_url
                    ? <img src={video.thumbnail_url} alt={video.title} className="w-full h-full object-cover" />
                    : <div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">No thumbnail</div>
                  }
                  <Badge className="absolute top-2 right-2" variant={STATUS_BADGE[video.status].variant}>{STATUS_BADGE[video.status].label}</Badge>
                  {video.duration_seconds && (
                    <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>
                  )}
                </div>
                <CardContent className="p-3">
                  <p className="text-sm font-medium line-clamp-2">{video.title}</p>
                  <p className="text-xs text-muted-foreground mt-1">{video.view_count.toLocaleString()} views</p>
                </CardContent>
              </Card>
            </Link>
          ))
        }
      </div>
    </div>
  )
}
```

## Common mistakes

- **Routing the video file upload through a Supabase Edge Function** — Edge Functions have a request body size limit (around 1MB by default for Supabase). A video file of even a few minutes will exceed this limit and the upload will fail. Fix: 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.
- **Setting the playback_id before the webhook confirms transcoding is complete** — Mux and Cloudflare return an asset ID immediately on upload acceptance, but the playback ID is only available after transcoding finishes (30 seconds to several minutes). Using the upload ID as a playback ID will result in a broken player. Fix: 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'.
- **Not verifying the webhook signature from Mux** — Without signature verification, any HTTP POST to your webhook endpoint could forge a video.asset.ready event and set any video's playback_id to an attacker-controlled value. Fix: 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.
- **Saving watch progress on every timeupdate event** — The HTML5 video timeupdate event fires 4 times per second. Calling a Supabase upsert 4 times per second generates massive database load and will quickly exhaust your free tier row count. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/video-streaming-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/video-streaming-backend
