Build a video streaming platform with V0 featuring Mux Direct Uploads for transcoding and HLS adaptive streaming, chunked uploads with @mux/upchunk for progress tracking, webhook-driven status updates, and a video feed with viewer analytics. You'll handle large file uploads without hitting Vercel's 4.5MB body limit — all in about 2-4 hours.
What you're building
Video platforms need to handle large file uploads, transcode videos into multiple quality levels, and stream adaptively based on the viewer's connection speed. Building this from scratch with FFmpeg would take weeks. Mux handles all of it through their API.
V0 generates the upload interface, video player, feed, and analytics dashboard from prompts. Mux processes uploaded videos into HLS adaptive streams with automatic thumbnail generation. Supabase stores video metadata and view analytics. The client uploads directly to Mux using chunked uploads, completely bypassing Vercel's 4.5MB serverless body limit.
The architecture uses an API route to create Mux Direct Upload URLs, a webhook handler for transcoding completion events, @mux/mux-player-react for the player, and Server Components for the video feed and analytics.
Final result
A video streaming platform with chunked uploads, adaptive HLS playback, processing status tracking, playlist management, and viewer analytics.
Tech stack
Prerequisites
- A V0 account (Premium recommended for the project complexity)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Mux account with API Access Token (Token ID + Token Secret)
- No additional services needed — Mux handles transcoding, storage, and CDN delivery
Build steps
Set up the videos, views, and playlists database schema
Open V0 and create a new project. Use the Connect panel to add Supabase. Create the tables for videos, view tracking, playlists, and playlist-video relationships.
1CREATE TABLE videos (2 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),3 owner_id uuid NOT NULL,4 title text NOT NULL,5 description text,6 thumbnail_url text,7 status text DEFAULT 'processing'8 CHECK (status IN ('processing','ready','failed','archived')),9 duration_seconds int,10 views_count int DEFAULT 0,11 upload_id text,12 playback_id text,13 created_at timestamptz DEFAULT now()14);1516CREATE TABLE video_views (17 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),18 video_id uuid REFERENCES videos(id) ON DELETE CASCADE,19 viewer_id uuid,20 watch_duration_seconds int DEFAULT 0,21 completed boolean DEFAULT false,22 created_at timestamptz DEFAULT now()23);2425CREATE TABLE playlists (26 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),27 owner_id uuid NOT NULL,28 title text NOT NULL,29 is_public boolean DEFAULT true,30 created_at timestamptz DEFAULT now()31);3233CREATE TABLE playlist_videos (34 playlist_id uuid REFERENCES playlists(id) ON DELETE CASCADE,35 video_id uuid REFERENCES videos(id) ON DELETE CASCADE,36 position int NOT NULL,37 PRIMARY KEY (playlist_id, video_id)38);3940CREATE OR REPLACE FUNCTION increment_views(p_video_id uuid)41RETURNS void AS $$42 UPDATE videos SET views_count = views_count + 143 WHERE id = p_video_id;44$$ LANGUAGE sql;Pro tip: Use V0's prompt queuing — queue the schema, upload page, video player, and feed page as four separate prompts while you set up the Mux account.
Expected result: Four tables created with a views counter RPC function. The videos table stores Mux playback_id for streaming and status for processing state.
Create the Mux Direct Upload endpoint
Build an API route that creates a Mux Direct Upload URL. The client will upload video chunks directly to Mux using this URL, completely bypassing your server and Vercel's body size limit.
1import { NextRequest, NextResponse } from 'next/server'2import Mux from '@mux/mux-node'3import { createClient } from '@/lib/supabase/server'45const mux = new Mux({6 tokenId: process.env.MUX_TOKEN_ID!,7 tokenSecret: process.env.MUX_TOKEN_SECRET!,8})910export async function POST(req: NextRequest) {11 const supabase = await createClient()12 const { title, description } = await req.json()1314 const user = (await supabase.auth.getUser()).data.user15 if (!user) {16 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })17 }1819 const upload = await mux.video.uploads.create({20 cors_origin: process.env.NEXT_PUBLIC_APP_URL ?? '*',21 new_asset_settings: {22 playback_policy: ['public'],23 encoding_tier: 'baseline',24 },25 })2627 const { data: video } = await supabase28 .from('videos')29 .insert({30 owner_id: user.id,31 title,32 description,33 upload_id: upload.id,34 })35 .select()36 .single()3738 return NextResponse.json({39 videoId: video?.id,40 uploadUrl: upload.url,41 })42}Pro tip: Set cors_origin to your production domain in Mux upload settings. Using '*' works for development but should be restricted in production for security.
Expected result: The API returns a Mux Direct Upload URL. The client uses this URL with @mux/upchunk to upload video files of any size directly to Mux.
Build the Mux webhook handler for transcoding events
Create a webhook endpoint that Mux calls when video processing completes. It updates the video status and stores the playback_id for streaming.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'3import crypto from 'crypto'45const supabase = createClient(6 process.env.NEXT_PUBLIC_SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910export async function POST(req: NextRequest) {11 const rawBody = await req.text()12 const signature = req.headers.get('mux-signature') ?? ''1314 const expectedSig = crypto15 .createHmac('sha256', process.env.MUX_WEBHOOK_SECRET!)16 .update(rawBody)17 .digest('hex')1819 if (signature !== `sha256=${expectedSig}`) {20 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })21 }2223 const event = JSON.parse(rawBody)2425 if (event.type === 'video.asset.ready') {26 const asset = event.data27 const playbackId = asset.playback_ids?.[0]?.id2829 await supabase30 .from('videos')31 .update({32 status: 'ready',33 playback_id: playbackId,34 duration_seconds: Math.round(asset.duration ?? 0),35 thumbnail_url: `https://image.mux.com/${playbackId}/thumbnail.webp`,36 })37 .eq('upload_id', asset.upload_id)38 }3940 if (event.type === 'video.asset.errored') {41 await supabase42 .from('videos')43 .update({ status: 'failed' })44 .eq('upload_id', event.data.upload_id)45 }4647 return NextResponse.json({ received: true })48}Pro tip: Always use request.text() instead of request.json() for webhook handlers. Signature verification requires the raw body string. Parsing to JSON first changes the string representation.
Expected result: When Mux finishes transcoding, the webhook updates the video status to 'ready' and stores the playback_id. Failed transcodes are marked as 'failed'.
Build the upload page with chunked upload progress
Create the upload form with @mux/upchunk for chunked file uploads. The client uploads directly to Mux, showing a Progress bar, without any data passing through your server.
1// Paste this prompt into V0's AI chat:2// Build a video upload page at app/upload/page.tsx with:3// 1. Client component ('use client') with shadcn/ui Input for title, Textarea for description4// 2. File input accepting video/* files5// 3. On submit: POST to /api/mux/upload to get the upload URL, then use @mux/upchunk UpChunk.createUpload() to send the file in chunks6// 4. shadcn/ui Progress bar showing upload percentage from upchunk's 'progress' event7// 5. Status text: 'Uploading...' → 'Processing...' → redirect to video page when ready8// 6. Error handling for failed uploads with Alert component9// 7. File size display and video preview before upload10// Install @mux/upchunk as a dependency.Expected result: An upload page where selecting a video file and clicking Upload sends chunks directly to Mux via the Direct Upload URL, with a Progress bar tracking completion percentage.
Build the video player and feed pages
Create the video player page with @mux/mux-player-react for adaptive HLS streaming, and the video feed page showing all ready videos in a Card grid.
1// Paste this prompt into V0's AI chat:2// Build two pages:3// 1. app/videos/[id]/page.tsx — Video player page:4// - Server Component fetching video by ID from Supabase5// - @mux/mux-player-react MuxPlayer with playbackId, in shadcn/ui AspectRatio (16:9)6// - Title, description, view count, upload date below player7// - Client component wrapper that POSTs to /api/views on play events8// 2. app/videos/page.tsx — Video feed:9// - Server Component fetching all videos WHERE status = 'ready'10// - Grid of shadcn/ui Cards with Mux thumbnail (image.mux.com/{playbackId}/thumbnail.webp)11// - Duration overlay, view count, title, upload date12// - Badge for 'processing' videos (show skeleton Card)13// - Pagination with limit 12 per page14// Install @mux/mux-player-react as a dependency.Expected result: A video feed page with thumbnail Cards linking to individual player pages. The player uses Mux's adaptive HLS streaming, automatically adjusting quality based on the viewer's connection.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'3import crypto from 'crypto'45const supabase = createClient(6 process.env.NEXT_PUBLIC_SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910export async function POST(req: NextRequest) {11 const rawBody = await req.text()12 const signature = req.headers.get('mux-signature') ?? ''1314 const expectedSig = crypto15 .createHmac('sha256', process.env.MUX_WEBHOOK_SECRET!)16 .update(rawBody)17 .digest('hex')1819 if (signature !== `sha256=${expectedSig}`) {20 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })21 }2223 const event = JSON.parse(rawBody)2425 if (event.type === 'video.asset.ready') {26 const asset = event.data27 const playbackId = asset.playback_ids?.[0]?.id2829 await supabase30 .from('videos')31 .update({32 status: 'ready',33 playback_id: playbackId,34 duration_seconds: Math.round(asset.duration ?? 0),35 thumbnail_url: `https://image.mux.com/${playbackId}/thumbnail.webp`,36 })37 .eq('upload_id', asset.upload_id)38 }3940 if (event.type === 'video.asset.errored') {41 await supabase42 .from('videos')43 .update({ status: 'failed' })44 .eq('upload_id', event.data.upload_id)45 }4647 return NextResponse.json({ received: true })48}Customization ideas
Add video chapters
Create a chapters table with timestamps and titles. Display chapter markers on the Mux player timeline and a chapter list below the video for easy navigation.
Add comments and reactions
Build a threaded comment system below videos with real-time updates via Supabase Realtime. Add emoji reactions that appear as floating overlays on the player.
Add video transcription
Use Mux's auto-generated captions or integrate with Deepgram/Whisper API for transcription. Display captions on the player and make videos searchable by transcript content.
Add monetization with Stripe
Gate premium videos behind a Stripe subscription. Check subscription status in middleware before rendering the player page, redirecting non-subscribers to a pricing page.
Common pitfalls
Pitfall: Uploading video files through your API route instead of using Direct Uploads
How to avoid: Use Mux Direct Uploads. Your API creates a signed upload URL, then the client uploads chunks directly to Mux using @mux/upchunk. Your server never touches the video file.
Pitfall: Using request.json() in the webhook handler
How to avoid: Always use request.text() to get the raw body for HMAC verification, then parse with JSON.parse() after verification succeeds.
Pitfall: Polling for video status instead of using webhooks
How to avoid: Register a webhook URL in the Mux dashboard. Mux sends video.asset.ready when transcoding completes. The webhook handler updates the database immediately.
Pitfall: Exposing MUX_TOKEN_SECRET in client-side code
How to avoid: Set MUX_TOKEN_ID and MUX_TOKEN_SECRET in V0's Vars tab without NEXT_PUBLIC_ prefix. Only the Direct Upload URL (a signed, temporary URL) is sent to the client.
Best practices
- Use Mux Direct Uploads with @mux/upchunk to bypass Vercel's 4.5MB serverless body limit entirely
- Always verify Mux webhook signatures using request.text() for the raw body and HMAC-SHA256
- Set MUX_TOKEN_ID, MUX_TOKEN_SECRET, and MUX_WEBHOOK_SECRET in V0's Vars tab (server-only, no NEXT_PUBLIC_ prefix)
- Use Mux's thumbnail URL pattern (image.mux.com/{playbackId}/thumbnail.webp) for automatic thumbnail generation
- Show a Processing Badge and Skeleton card for videos still being transcoded to set user expectations
- Log watch duration with periodic POSTs (every 30 seconds) rather than only on video end, to capture partial views accurately
- Use encoding_tier 'baseline' for faster transcoding during development, switch to 'smart' for production quality
AI prompts to try
Copy these prompts to build this project faster.
I'm building a video streaming platform with Next.js App Router, Supabase, and Mux. I need: 1) Mux Direct Upload URL creation in an API route, 2) Client-side chunked uploads with @mux/upchunk showing progress, 3) A webhook handler for video.asset.ready that updates Supabase with the playback_id, 4) The @mux/mux-player-react component for adaptive HLS streaming. Help me design the upload-to-playback pipeline.
Create a Mux webhook handler at app/api/webhooks/mux/route.ts that: 1) Reads the raw body with request.text(), 2) Verifies the mux-signature header using HMAC-SHA256 with MUX_WEBHOOK_SECRET, 3) Handles video.asset.ready by updating the Supabase videos table with playback_id, duration_seconds, thumbnail_url, and status='ready', 4) Handles video.asset.errored by setting status='failed', 5) Uses the service role key to bypass RLS.
Frequently asked questions
Why use Mux instead of processing video myself?
Mux handles transcoding into multiple quality levels (HLS adaptive streaming), CDN delivery, thumbnail generation, and player optimization. Building this with FFmpeg on Vercel serverless is impractical due to timeout limits, memory constraints, and the complexity of adaptive bitrate encoding.
How does the chunked upload work?
@mux/upchunk splits large video files into small chunks and uploads them directly to Mux's Direct Upload URL. Your server only creates the upload URL — the actual video data goes directly from the browser to Mux, bypassing Vercel's 4.5MB body limit. The upchunk library emits progress events for the Progress bar.
What happens during video processing?
After upload, Mux transcodes the video into multiple quality renditions (360p through 1080p+) for adaptive streaming. This takes 30 seconds to several minutes depending on video length. The video.asset.ready webhook fires when all renditions are complete.
What V0 plan do I need?
V0 Premium ($20/month) is recommended. The video platform involves multiple pages, API routes, and webhook handlers that require several prompt iterations. Mux has a free tier with 10GB storage and 20 minutes of video.
How much does Mux cost?
Mux pricing is usage-based: video encoding at $0.015/minute, storage at $0.007/GB/month, and streaming delivery at $0.00075/minute viewed. The free tier includes 10GB storage and is sufficient for development and small projects.
How do I deploy this?
Click Share then Publish in V0. Set MUX_TOKEN_ID, MUX_TOKEN_SECRET, and MUX_WEBHOOK_SECRET in V0's Vars tab (no NEXT_PUBLIC_ prefix). After deploying, register the webhook URL in the Mux dashboard: https://your-domain.vercel.app/api/webhooks/mux.
Can RapidDev help build a custom video platform?
Yes. RapidDev has built 600+ apps including video streaming platforms with content gating, multi-tenant architectures, and analytics dashboards. Book a free consultation to discuss your video platform requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation