Skip to main content
RapidDev - Software Development Agency

How to Build a Video Streaming Backend with Replit

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'll build

  • Video metadata API with title, description, visibility, view count, and processing status
  • Direct browser-to-provider upload flow using pre-signed URLs (avoids Replit file size limits)
  • Webhook receiver that updates video status when transcoding completes from Mux or Cloudflare Stream
  • Playlist management with ordered video collections
  • Watch progress tracking (resume from where you left off)
  • Creator dashboard with video management, status badges, and upload progress
  • Comments system with pagination for each video
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read2-4 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Mux (or Cloudflare Stream)Video Processing & Delivery
Replit AuthAuth
ReactFrontend

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

1

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.

prompt.txt
1// First, add to Replit Secrets (lock icon in sidebar):
2// MUX_TOKEN_ID = your_mux_token_id
3// MUX_TOKEN_SECRET = your_mux_token_secret
4// MUX_WEBHOOK_SECRET = your_mux_webhook_signing_secret
5
6// 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.

2

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.

server/routes/upload.js
1// server/routes/upload.js
2const express = require('express');
3const Mux = require('@mux/mux-node');
4const { db } = require('../db');
5const { videos } = require('../schema');
6
7const mux = new Mux({
8 tokenId: process.env.MUX_TOKEN_ID,
9 tokenSecret: process.env.MUX_TOKEN_SECRET,
10});
11
12const router = express.Router();
13
14// Step 1: Client requests a pre-signed upload URL
15router.post('/api/videos/upload', express.json(), async (req, res) => {
16 const { title, description, visibility = 'public' } = req.body;
17 const creatorId = req.user.id;
18
19 try {
20 // Create a direct upload URL at Mux
21 const upload = await mux.video.uploads.create({
22 cors_origin: '*', // restrict to your domain in production
23 new_asset_settings: {
24 playback_policy: [visibility === 'public' ? 'public' : 'signed'],
25 video_quality: 'basic', // 'plus' for 1080p
26 },
27 });
28
29 // Create the video row in our DB with processing status
30 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 ready
36 status: 'processing',
37 visibility,
38 }).returning();
39
40 // Return both the Mux upload URL and our DB video ID
41 res.json({
42 videoId: video.id,
43 uploadUrl: upload.url, // client uploads directly to this URL
44 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});
51
52module.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.

3

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.

server/routes/webhook-mux.js
1// server/routes/webhook-mux.js
2const express = require('express');
3const Mux = require('@mux/mux-node');
4const { db } = require('../db');
5const { videos } = require('../schema');
6const { eq } = require('drizzle-orm');
7
8const mux = new Mux({
9 tokenId: process.env.MUX_TOKEN_ID,
10 tokenSecret: process.env.MUX_TOKEN_SECRET,
11});
12
13const router = express.Router();
14
15// IMPORTANT: mounted with express.raw() in server/index.js BEFORE express.json()
16router.post('/', async (req, res) => {
17 // Verify Mux webhook signature
18 const muxSignature = req.headers['mux-signature'];
19
20 try {
21 // Mux uses its own signature verification — NOT Stripe's constructEvent
22 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 }
27
28 const event = JSON.parse(req.body.toString());
29 console.log('[mux-webhook] event type:', event.type);
30
31 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 = playbackId
37 ? `https://stream.mux.com/${playbackId}.m3u8`
38 : null;
39 const thumbnailUrl = playbackId
40 ? `https://image.mux.com/${playbackId}/thumbnail.jpg`
41 : null;
42
43 // Find video row by the Mux upload ID stored in provider_asset_id
44 await db.update(videos)
45 .set({
46 status: 'ready',
47 providerAssetId: asset.id, // now the real asset ID
48 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 }
66
67 res.json({ received: true });
68 } catch (err) {
69 console.error('[mux-webhook] handler error:', err);
70 res.status(500).send('handler error');
71 }
72});
73
74module.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.

4

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.

server/routes/watch.js
1// server/routes/watch.js
2const express = require('express');
3const { db } = require('../db');
4const { watchHistory, videos } = require('../schema');
5const { eq, and } = require('drizzle-orm');
6
7const router = express.Router();
8
9// PATCH /api/videos/:id/progress — save watch position
10router.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;
14
15 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 });
29
30 res.json({ saved: true });
31});
32
33// GET /api/videos/:id — detail with view count increment
34router.get('/api/videos/:id', async (req, res) => {
35 const videoId = parseInt(req.params.id);
36 const userId = req.user?.id;
37
38 const [video] = await db.select().from(videos)
39 .where(eq(videos.id, videoId)).limit(1);
40
41 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' });
43
44 // 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(() => {});
49
50 // Load user's watch progress if logged in
51 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 }
59
60 res.json({ ...video, resumeAt: progress });
61});
62
63module.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.

5

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.

server/index.js
1// Deployment checklist:
2// 1. In Replit's Publish pane, set deployment type to Reserved VM
3// 2. Add Deployment Secrets: MUX_TOKEN_ID, MUX_TOKEN_SECRET, MUX_WEBHOOK_SECRET
4// (Workspace Secrets are NOT used in deployments)
5// 3. Deploy and copy your *.replit.app URL
6// 4. In Mux Dashboard → Settings → Webhooks → Add endpoint:
7// URL: https://your-app.replit.app/api/webhooks/mux
8// Events: video.asset.ready, video.asset.errored, video.asset.deleted
9// 5. Copy the signing secret and update MUX_WEBHOOK_SECRET in Deployment Secrets
10// 6. Redeploy to pick up the new secret
11
12// server/index.js — middleware ordering for webhooks
13const express = require('express');
14const app = express();
15
16// Webhook route FIRST with raw body parser
17const muxWebhook = require('./routes/webhook-mux');
18app.use('/api/webhooks/mux',
19 express.raw({ type: 'application/json' }),
20 muxWebhook
21);
22
23// JSON parser for all other routes
24app.use(express.json());
25
26const 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

server/routes/upload.js
1const express = require('express');
2const Mux = require('@mux/mux-node');
3const { db } = require('../db');
4const { videos } = require('../schema');
5const { eq } = require('drizzle-orm');
6
7const mux = new Mux({
8 tokenId: process.env.MUX_TOKEN_ID,
9 tokenSecret: process.env.MUX_TOKEN_SECRET,
10});
11
12const router = express.Router();
13
14// POST /api/videos/upload — get pre-signed URL for direct browser upload
15router.post('/api/videos/upload', express.json(), async (req, res) => {
16 const { title, description = '', visibility = 'public' } = req.body;
17 const creatorId = req.user.id;
18
19 if (!title || title.trim().length === 0) {
20 return res.status(400).json({ error: 'Title is required' });
21 }
22
23 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 });
32
33 const [video] = await db
34 .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 ready
41 status: 'processing',
42 visibility,
43 })
44 .returning();
45
46 res.json({
47 videoId: video.id,
48 uploadUrl: upload.url, // Browser PUTs the file to this URL
49 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});
56
57// GET /api/videos — list ready public videos
58router.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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.