Ryan Trann
December 24, 2025
7 min read

Video is quickly becoming the format of choice for content on the internet. If you're building an MVP with user-generated video, you'll need to let users upload a file, store it, and play it back. This guide walks through a minimal video upload implementation using Next.js with Supabase or Firebase storage, a CDN backed URL, and a database table for tracking videos.
First time with Supabase or Firebase?
Check out the initial setup guides to get your project ready.
A reliable user generated content (UGC) video flow needs five pieces:
<video> elementDatabases store the metadata you'll need when it's time to programmatically get the right file for the right situation. Metadata such as public_url, id, user_id etc.
Let's look at two common database examples.
Create videos table
sqlcreate table videos (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id),
storage_path text NOT null,
public_url text NOT null,
mime_type text,
size_bytes bigint,
created_at timestamptz default now()
);Firestore document creation
tsximport { doc, setDoc } from "firebase/firestore";
await setDoc(doc(db, "videos", crypto.randomUUID()), {
userId,
storagePath: path,
publicUrl: url,
mimeType: file.type,
sizeBytes: file.size,
createdAt: Date.now(),
});Before implementing uploads, you should decide where the video files will live and how they'll be served to clients. Generally speaking, this means cloud object storage backed by a CDN.
Both Supabase Storage and Firebase Storage provide the core features for video uploads:
If you're hosting and serving videos, either option can work for you.
The right choice depends less on "CDN features" and more on whether your video pipeline is backend driven or client driven.
The core flow is: the user selects a file, you upload it to storage, and then you write the metadata entry to your database.
Below are both Supabase and Firebase implementations, including direct uploads and signed upload URLs.
Client component
tsx"use client";
import { useState } from "react";
export default function UploadPage() {
const [file, setFile] = useState<File | null>(null);
async function handleUpload() {
if (!file) return;
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", { method: "POST", body: formData });
}
return (
<div>
<input type="file" accept="video/*" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<button onClick={handleUpload}>Upload</button>
</div>
);
}API route
tsximport { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
export async function POST(req: NextRequest) {
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY
);
const data = await req.formData();
const file = data.get("file") as File;
const extension = file.name.split(".").pop();
const videoId = crypto.randomUUID();
const path = `videos/${videoId}.${extension}`;
const { error } = await supabase.storage
.from("videos")
.upload(path, file, { contentType: file.type });
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
const url = `${process.env.SUPABASE_URL}/storage/v1/object/public/${path}`;
const { error: dbError } = await supabase.from("videos").insert({
id: videoId,
user_id: "some-user-id",
storage_path: path,
public_url: url,
mime_type: file.type,
size_bytes: file.size,
});
if (dbError) {
return NextResponse.json({ error: dbError.message }, { status: 500 });
}
return NextResponse.json({ url, path });
}API route for signed URL
tsxexport async function POST() {
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_KEY
);
const videoId = crypto.randomUUID();
const path = `videos/${videoId}.mp4`;
const { data, error } = await supabase.storage.from("videos").createSignedUploadUrl(path);
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json({ uploadUrl: data.signedUrl, path, videoId });
}Client upload with signed URL
tsxawait fetch(uploadUrl, { method: "PUT", body: file });Insert metadata after upload
tsxawait supabase.from("videos").insert({
id: videoId,
user_id,
storage_path: path,
public_url: `${process.env.SUPABASE_URL}/storage/v1/object/public/${path}`,
mime_type: file.type,
size_bytes: file.size,
});Firebase handles everything client-side.
Client upload (Firebase Storage)
tsximport { getStorage, ref, uploadBytesResumable, getDownloadURL } from "firebase/storage";
const storage = getStorage();
const videoId = crypto.randomUUID()
const path = `videos/${videoId}`;
const storageRef = ref(storage, path);
const task = uploadBytesResumable(storageRef, file);
task.on("state_changed", null, console.error, async () => {
const url = await getDownloadURL(task.snapshot.ref);
await setDoc(doc(db, "videos", crypto.randomUUID()), {
id: videoId,
userId,
storagePath: path,
publicUrl: url,
mimeType: file.type,
sizeBytes: file.size,
createdAt: Date.now(),
});
});Once you have a video URL:
Basic video element
html<video
src={url}
controls
playsInline
/>For looping autoplay:
Autoplay video
html<video
src={url}
controls
autoplay
muted
loop
playsInline
/>File size validation
tsxif (file.size > 200 * 1024 * 1024) {
alert("Max size is 200MB");
return;
}MIME type validation
tsxif (!file.type.startsWith("video/")) {
alert("File must be a video");
return;
}This step requires ffmpeg, which can be a tricky thing to get setup properly in a Node.js environment. I recommend you skip this early on and add it later when you've got time to invest. Thumbnail images or posters can be really useful for UX purposes to give the user the perception of the UI being ready, while the video is buffering.
Client → API or Signed URL → Storage Bucket → CDN → DB record → <video> playback
That's the MVP video pipeline for adding user-generated video to a Next.js application without dealing with transcoding, queues, workers and distributed architecture.
This system won't last much longer than an MVP—if you need something reliable and scalable but you don't want to build it yourself, give Hyperserve a try. We've done all the hard work of building out Video Architecture at Scale and wrapped it in a simple API that makes adding video nearly as easy as adding images.
Video Hosting API made simple.
© 2025 Hyperserve. All rights reserved.