Build a video streaming backend with Replit in 2-4 hours. You'll create an Express API with PostgreSQL (Drizzle ORM) for video metadata, playlists, and watch history, integrated with a third-party provider (Mux, Cloudflare Stream, or Bunny.net) for the actual video processing and delivery. Replit handles the API and user experience — not transcoding. Deploy on Reserved VM for reliable webhook reception.
What you're building
Video streaming requires two separate layers: a metadata and user experience layer (what your app handles) and a video processing and delivery layer (what a specialized provider handles). Replit's compute and storage are not suited for video transcoding — storing and converting raw video files requires terabytes of disk, GPU time, and a global CDN. The right architecture delegates transcoding to Mux or Cloudflare Stream while your Replit app handles everything else.
Replit Agent generates the Express API and React frontend from one prompt. The most important pattern is the upload flow: the browser requests a pre-signed upload URL from your Express API, uploads the raw video file directly to Mux or Cloudflare (bypassing Replit entirely), and the provider sends a webhook when transcoding is complete. Your webhook receiver updates the video status in PostgreSQL.
The architecture separates concerns cleanly: Express handles authentication, metadata queries, watch progress, comments, and playlists. Mux or Cloudflare handles encoding, thumbnail generation, CDN delivery, and adaptive bitrate streaming. Incoming webhooks require a deployed URL, so you must deploy before registering the webhook endpoint with your provider.
Final result
A full video streaming backend with direct browser upload, provider-based transcoding, a webhook status updater, playlist management, watch progress tracking, and a creator dashboard — deployed on Reserved VM for always-on webhook reception.
Tech stack
Prerequisites
- A Replit account (Free tier is sufficient for development)
- A Mux account (free trial available at mux.com) OR a Cloudflare Stream account — you need API credentials before starting
- Basic understanding of what webhooks and APIs do (no coding experience needed)
- Optional: a Cloudflare account if you prefer Cloudflare Stream over Mux
Build steps
Set up API credentials and scaffold the project with Agent
Sign up for Mux at mux.com (or Cloudflare Stream) and create API credentials. Add your credentials to Replit Secrets (lock icon in sidebar) before generating the app. Then use the Agent prompt below to scaffold the full project.
1// First, add to Replit Secrets (lock icon in sidebar):2// MUX_TOKEN_ID = your_mux_token_id3// MUX_TOKEN_SECRET = your_mux_token_secret4// MUX_WEBHOOK_SECRET = your_mux_webhook_signing_secret56// Then paste this into Replit Agent:7// Build a video streaming backend with Express and PostgreSQL (Drizzle ORM).8// Schema:9// videos (id serial PK, creator_id text, title text, description text,10// provider text enum mux/cloudflare/bunny, provider_asset_id text,11// playback_url text, thumbnail_url text, duration_seconds integer,12// status text default processing enum processing/ready/error,13// visibility text default public enum public/unlisted/private,14// view_count integer default 0, created_at),15// playlists (id serial PK, creator_id text, name text, description text,16// is_public bool default true, created_at),17// playlist_videos (id serial PK, playlist_id int references playlists,18// video_id int references videos, position int, UNIQUE playlist_id+video_id),19// watch_history (id serial PK, user_id text, video_id int references videos,20// progress_seconds int default 0, last_watched_at, UNIQUE user_id+video_id),21// comments (id serial PK, video_id int references videos,22// user_id text, content text, created_at).23// Routes: POST /api/videos/upload (get pre-signed upload URL from Mux, create video row),24// POST /api/webhooks/mux (verify Mux signature, update video status on completion),25// GET /api/videos (list ready public videos), GET /api/videos/:id (detail, increment view_count),26// PATCH /api/videos/:id (update title, description, visibility),27// DELETE /api/videos/:id (delete from Mux + DB),28// POST /api/playlists, POST /api/playlists/:id/videos,29// GET /api/playlists/:id (ordered videos),30// PATCH /api/videos/:id/progress (save watch position),31// GET /api/videos/:id/comments, POST /api/videos/:id/comments.32// Store MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET in process.env.33// React: video gallery grid, video player with Mux Player embed, creator dashboard.34// Bind server to 0.0.0.0.Pro tip: Mux has the best developer experience for beginners. Cloudflare Stream is cheaper at scale. Both work with this pattern. Pick Mux if you want to get started fastest.
Expected result: Agent creates the full project with the Mux upload route, webhook handler, and React frontend. The video gallery is visible in preview.
Implement the direct browser upload flow
The upload flow has two steps: (1) the browser asks your API for a pre-signed upload URL, and (2) the browser uploads directly to Mux. This bypasses Replit's server entirely for the file transfer — no 50MB upload limits, no server memory issues. The Express route only creates the URL and the database row.
1// server/routes/upload.js2const express = require('express');3const Mux = require('@mux/mux-node');4const { db } = require('../db');5const { videos } = require('../schema');67const mux = new Mux({8 tokenId: process.env.MUX_TOKEN_ID,9 tokenSecret: process.env.MUX_TOKEN_SECRET,10});1112const router = express.Router();1314// Step 1: Client requests a pre-signed upload URL15router.post('/api/videos/upload', express.json(), async (req, res) => {16 const { title, description, visibility = 'public' } = req.body;17 const creatorId = req.user.id;1819 try {20 // Create a direct upload URL at Mux21 const upload = await mux.video.uploads.create({22 cors_origin: '*', // restrict to your domain in production23 new_asset_settings: {24 playback_policy: [visibility === 'public' ? 'public' : 'signed'],25 video_quality: 'basic', // 'plus' for 1080p26 },27 });2829 // Create the video row in our DB with processing status30 const [video] = await db.insert(videos).values({31 creatorId,32 title,33 description,34 provider: 'mux',35 providerAssetId: upload.id, // will be updated when asset is ready36 status: 'processing',37 visibility,38 }).returning();3940 // Return both the Mux upload URL and our DB video ID41 res.json({42 videoId: video.id,43 uploadUrl: upload.url, // client uploads directly to this URL44 uploadId: upload.id,45 });46 } catch (err) {47 console.error('[upload] Mux error:', err.message);48 res.status(500).json({ error: 'Failed to create upload URL' });49 }50});5152module.exports = router;Pro tip: The client uploads the video file by sending a PUT request to the uploadUrl with the file as the request body. Use the Fetch API with `method: 'PUT', body: file`. Show a progress bar using the upload XMLHttpRequest's progress event.
Build the Mux webhook receiver to update video status
When Mux finishes transcoding, it sends a webhook to your deployed URL. The handler verifies the Mux signature, then updates the video row with the playback URL and thumbnail. This route must be deployed — Mux cannot reach a Replit workspace URL.
1// server/routes/webhook-mux.js2const express = require('express');3const Mux = require('@mux/mux-node');4const { db } = require('../db');5const { videos } = require('../schema');6const { eq } = require('drizzle-orm');78const mux = new Mux({9 tokenId: process.env.MUX_TOKEN_ID,10 tokenSecret: process.env.MUX_TOKEN_SECRET,11});1213const router = express.Router();1415// IMPORTANT: mounted with express.raw() in server/index.js BEFORE express.json()16router.post('/', async (req, res) => {17 // Verify Mux webhook signature18 const muxSignature = req.headers['mux-signature'];1920 try {21 // Mux uses its own signature verification — NOT Stripe's constructEvent22 mux.webhooks.verify(req.body, req.headers, process.env.MUX_WEBHOOK_SECRET);23 } catch (err) {24 console.error('[mux-webhook] signature failed:', err.message);25 return res.status(401).send('Invalid signature');26 }2728 const event = JSON.parse(req.body.toString());29 console.log('[mux-webhook] event type:', event.type);3031 try {32 switch (event.type) {33 case 'video.asset.ready': {34 const asset = event.data;35 const playbackId = asset.playback_ids?.[0]?.id;36 const playbackUrl = playbackId37 ? `https://stream.mux.com/${playbackId}.m3u8`38 : null;39 const thumbnailUrl = playbackId40 ? `https://image.mux.com/${playbackId}/thumbnail.jpg`41 : null;4243 // Find video row by the Mux upload ID stored in provider_asset_id44 await db.update(videos)45 .set({46 status: 'ready',47 providerAssetId: asset.id, // now the real asset ID48 playbackUrl,49 thumbnailUrl,50 durationSeconds: Math.round(asset.duration ?? 0),51 })52 .where(eq(videos.providerAssetId, event.object?.id ?? asset.id));53 break;54 }55 case 'video.asset.errored':56 await db.update(videos)57 .set({ status: 'error' })58 .where(eq(videos.providerAssetId, event.data.id));59 break;60 case 'video.asset.deleted':61 await db.update(videos)62 .set({ status: 'error' })63 .where(eq(videos.providerAssetId, event.data.id));64 break;65 }6667 res.json({ received: true });68 } catch (err) {69 console.error('[mux-webhook] handler error:', err);70 res.status(500).send('handler error');71 }72});7374module.exports = router;Expected result: After deploying and registering the webhook URL in the Mux Dashboard, uploading a test video transitions from 'processing' to 'ready' in your database within 30-90 seconds.
Implement watch progress saving and playlist ordering
Watch progress lets users resume where they left off. The PATCH route uses an upsert pattern — either create a new watch_history row or update the progress_seconds if one already exists. Playlist ordering uses integer positions with the same move/reorder pattern as a task board.
1// server/routes/watch.js2const express = require('express');3const { db } = require('../db');4const { watchHistory, videos } = require('../schema');5const { eq, and } = require('drizzle-orm');67const router = express.Router();89// PATCH /api/videos/:id/progress — save watch position10router.patch('/api/videos/:id/progress', express.json(), async (req, res) => {11 const videoId = parseInt(req.params.id);12 const userId = req.user.id;13 const { progressSeconds } = req.body;1415 await db.insert(watchHistory)16 .values({17 userId,18 videoId,19 progressSeconds,20 lastWatchedAt: new Date(),21 })22 .onConflictDoUpdate({23 target: [watchHistory.userId, watchHistory.videoId],24 set: {25 progressSeconds,26 lastWatchedAt: new Date(),27 },28 });2930 res.json({ saved: true });31});3233// GET /api/videos/:id — detail with view count increment34router.get('/api/videos/:id', async (req, res) => {35 const videoId = parseInt(req.params.id);36 const userId = req.user?.id;3738 const [video] = await db.select().from(videos)39 .where(eq(videos.id, videoId)).limit(1);4041 if (!video) return res.status(404).json({ error: 'Video not found' });42 if (video.status !== 'ready') return res.status(404).json({ error: 'Video not available' });4344 // Increment view count asynchronously (don't await)45 db.update(videos)46 .set({ viewCount: db.sql`view_count + 1` })47 .where(eq(videos.id, videoId))48 .catch(() => {});4950 // Load user's watch progress if logged in51 let progress = null;52 if (userId) {53 const [hist] = await db.select()54 .from(watchHistory)55 .where(and(eq(watchHistory.userId, userId), eq(watchHistory.videoId, videoId)))56 .limit(1);57 progress = hist?.progressSeconds ?? 0;58 }5960 res.json({ ...video, resumeAt: progress });61});6263module.exports = router;Pro tip: Call PATCH /api/videos/:id/progress every 10 seconds while the video is playing, not on every timeupdate event. This reduces database writes from hundreds to a handful per viewing session.
Deploy on Reserved VM and register the webhook
Unlike other apps in this series, this one MUST be on Reserved VM. Webhooks from Mux fire when transcoding completes — potentially minutes after the upload. If the app is asleep (Autoscale scale-to-zero), the webhook misses and the video stays in 'processing' status forever. After deploying, register your webhook URL in the Mux Dashboard.
1// Deployment checklist:2// 1. In Replit's Publish pane, set deployment type to Reserved VM3// 2. Add Deployment Secrets: MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET4// (Workspace Secrets are NOT used in deployments)5// 3. Deploy and copy your *.replit.app URL6// 4. In Mux Dashboard → Settings → Webhooks → Add endpoint:7// URL: https://your-app.replit.app/api/webhooks/mux8// Events: video.asset.ready, video.asset.errored, video.asset.deleted9// 5. Copy the signing secret and update MUX_WEBHOOK_SECRET in Deployment Secrets10// 6. Redeploy to pick up the new secret1112// server/index.js — middleware ordering for webhooks13const express = require('express');14const app = express();1516// Webhook route FIRST with raw body parser17const muxWebhook = require('./routes/webhook-mux');18app.use('/api/webhooks/mux',19 express.raw({ type: 'application/json' }),20 muxWebhook21);2223// JSON parser for all other routes24app.use(express.json());2526const PORT = process.env.PORT || 3000;27app.listen(PORT, '0.0.0.0', () => {28 console.log(`Video streaming backend running on port ${PORT}`);29});Expected result: After deploying on Reserved VM and registering the webhook in Mux Dashboard, uploads trigger the transcoding pipeline and the video transitions to 'ready' status in your database automatically.
Complete code
1const express = require('express');2const Mux = require('@mux/mux-node');3const { db } = require('../db');4const { videos } = require('../schema');5const { eq } = require('drizzle-orm');67const mux = new Mux({8 tokenId: process.env.MUX_TOKEN_ID,9 tokenSecret: process.env.MUX_TOKEN_SECRET,10});1112const router = express.Router();1314// POST /api/videos/upload — get pre-signed URL for direct browser upload15router.post('/api/videos/upload', express.json(), async (req, res) => {16 const { title, description = '', visibility = 'public' } = req.body;17 const creatorId = req.user.id;1819 if (!title || title.trim().length === 0) {20 return res.status(400).json({ error: 'Title is required' });21 }2223 try {24 const upload = await mux.video.uploads.create({25 cors_origin: '*',26 new_asset_settings: {27 playback_policy: [visibility === 'public' ? 'public' : 'signed'],28 video_quality: 'basic',29 mp4_support: 'capped-1080p',30 },31 });3233 const [video] = await db34 .insert(videos)35 .values({36 creatorId,37 title: title.trim(),38 description: description.trim(),39 provider: 'mux',40 providerAssetId: upload.id, // Mux upload ID, replaced by asset ID on ready41 status: 'processing',42 visibility,43 })44 .returning();4546 res.json({47 videoId: video.id,48 uploadUrl: upload.url, // Browser PUTs the file to this URL49 uploadId: upload.id,50 });51 } catch (err) {52 console.error('[upload] Mux API error:', err.message);53 res.status(500).json({ error: 'Failed to create upload session' });54 }55});5657// GET /api/videos — list ready public videos58router.get('/api/videos', async (req, res) => {59 const { creatorId, limit = 20, offset = 0 } = req.query;60 const { eq: eqOp, and: andOp } = require('drizzle-orm');Customization ideas
Paid video access with Mux signed playback URLs
Set `playback_policy: ['signed']` when creating the Mux asset. Generate a JWT signed with your Mux signing key via `mux.jwt.signPlaybackId()` in the GET /api/videos/:id route. Only users who have paid (checked against your subscriptions table) receive the signed URL.
Chapter markers and timestamps
Add a `chapters` table with video_id, title, start_seconds, and end_seconds. Display chapter markers on the video player progress bar. Add a chapters list below the player so viewers can jump to specific sections.
Video analytics with Mux Data
Mux's Data API provides detailed analytics: views, watch time, buffering rates, and geographic breakdown. Add a GET /api/videos/:id/analytics route that fetches from the Mux Data API and displays the data in the creator dashboard.
Subtitles and captions
Use the Mux static renditions API to generate transcript tracks, or accept SRT file uploads from creators. Store subtitle file URLs in a `subtitles` table linked to the video. Pass the subtitle track to the Mux Player component via the `metadata-video-title` and custom tracks props.
Common pitfalls
Pitfall: Videos stay stuck in 'processing' status forever
How to avoid: Deploy on Reserved VM (always-on). This is the single most important requirement for this app. Autoscale is not suitable for webhook-driven status updates.
Pitfall: Upload fails with CORS error when uploading directly to Mux
How to avoid: During development, set `cors_origin: '*'`. In production, set it to your deployed domain: `cors_origin: 'https://your-app.replit.app'`.
Pitfall: Webhook signature verification fails
How to avoid: Mount the webhook route BEFORE app.use(express.json()) and use express.raw({ type: 'application/json' }) on that route. Mux's `webhooks.verify()` needs the raw bytes.
Best practices
- Never attempt video transcoding on Replit — always use Mux, Cloudflare Stream, or Bunny.net for video processing and CDN delivery
- Store MUX_TOKEN_ID, MUX_TOKEN_SECRET, and MUX_WEBHOOK_SECRET in Replit Secrets (lock icon) — add all three again in Deployment Secrets
- Deploy on Reserved VM — webhook-driven status updates from Mux require an always-on server
- Use the direct browser-to-Mux upload pattern — it avoids Replit's request size limits and reduces server load
- Mount the Mux webhook route BEFORE express.json() and use express.raw() — Mux signature verification requires the raw request body
- Save watch progress every 10 seconds, not on every player timeupdate event — this reduces database writes dramatically
- Test the full upload-transcode-webhook cycle with a short video (under 1 minute) before building the UI around it
AI prompts to try
Copy these prompts to build this project faster.
I'm building a video streaming backend with Express.js and PostgreSQL on Replit, using Mux for video processing. Help me implement the direct browser upload flow. The pattern is: (1) browser calls my Express API to get a Mux direct upload URL, (2) browser uploads the file directly to Mux, (3) Mux sends a webhook when transcoding is complete, (4) my webhook handler updates the video status. Write the Express route for step 1 using the @mux/mux-node SDK, and the React component for step 2 that shows an upload progress bar using XMLHttpRequest.
Add a video upload progress UI to my React frontend for the video streaming backend. When a creator selects a video file, the upload flow should be: (1) call POST /api/videos/upload to get the Mux uploadUrl, (2) upload the file to uploadUrl using XMLHttpRequest (not fetch — XHR has progress events), (3) show a progress bar that updates via xhr.upload.onprogress, (4) on completion, poll GET /api/videos/:id every 3 seconds until status changes from 'processing' to 'ready', then redirect to the video detail page. Show a 'Processing...' spinner during the wait.
Frequently asked questions
Why can't I just store and serve video files from Replit's built-in storage?
Replit's storage is not designed for large media files. A single 1-hour video at 1080p is 2-8 GB. Storing and serving this directly would exhaust your storage quota, lack a CDN for global delivery, and have no adaptive bitrate streaming. Mux and Cloudflare Stream handle encoding, storage, thumbnails, and CDN — your Replit app only manages metadata.
Is Mux free to get started?
Mux offers a free tier with limited storage and bandwidth, enough to build and test this app. Pricing is based on video minutes stored and delivered. For a small creator platform, costs are typically a few dollars per month. Cloudflare Stream is an alternative with a different pricing model (per-minute stored and delivered).
Why do I have to use Reserved VM instead of Autoscale?
Mux sends a webhook when transcoding completes, which can happen minutes after the upload. If your app has scaled to zero (Autoscale's idle behavior), the webhook hits a sleeping server and times out. Mux retries failed webhooks, but with Autoscale's 10-30 second cold start, the webhook often times out before the app is ready. Reserved VM keeps the server always-on.
How long does Mux transcoding take?
Mux typically transcodes a 1-minute video in under 60 seconds. For longer videos, expect roughly real-time processing. The video.asset.ready webhook fires when all quality levels are available. Your 'processing' status badge in the UI will update automatically when the webhook is received.
Can I offer paid video access with this setup?
Yes. Set `playback_policy: ['signed']` when creating the Mux asset. Generate signed playback JWTs using `mux.jwt.signPlaybackId()` in your video detail route. Only authenticated users who meet your payment/subscription requirements receive a signed URL. Unsigned requests to Mux are rejected.
What's the minimum Replit plan needed?
Free tier for development. For production, Reserved VM costs $6-20/month depending on resources. This is required for reliable webhook reception. The Mux API costs are separate and charged by Mux based on usage.
How do I test the upload webhook without deploying?
Mux provides a webhook testing tool in their Dashboard. You can also use the Replit workspace URL during development — it's not stable but good enough for initial testing. However, for production use, always use the deployed *.replit.app URL.
Can RapidDev help me build a custom video platform?
Yes. RapidDev has built 600+ apps including video platforms with live streaming, paid access, and creator monetization. Book a free consultation at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation