Ryan Trann
AUTHOR
February 7, 2026
7 min read
TL;DR
A practical guide to adding user-generated video uploads to a Next.js app using Supabase or Firebase storage. Covers the MVP pipeline (upload → metadata table → CDN delivery), how Supabase and Firebase differ for video, code for both direct-POST and signed-URL upload flows, and the validation/limits you shouldn't skip (file size, MIME, thumbnails). It also shows how to offload storage, transcoding, and delivery to Hyperserve while keeping your Supabase or Firebase database exactly as it is.

Video is quickly becoming the format of choice for content on the internet. To create an MVP with user-generated video uploads, you not only need to allow users to upload files, you also need to store them and let users play them back. This guide shows how to upload videos using Next.js with Supabase or Firebase storage, a CDN URL, and a database table to track videos.
First time with Supabase or Firebase?
To add video to your app MVP, you need a strong user-generated content (UGC) video flow. This flow requires five key parts:
<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(),
});
});The two options above store the raw file in your Supabase or Firebase bucket and serve it back as-is. That works for a few short clips, but the moment you're accepting video at scale, from your users or your own team, you need a robust media pipeline that transcodes each upload into multiple resolutions and generates thumbnails automatically. Building that yourself is a real project. The alternative is to keep your Supabase or Firebase database for auth and metadata and hand the video file to Hyperserve, which does exactly that. It's the same signed-URL shape you already saw with Supabase: your backend creates the upload, the browser uploads the file, then you confirm.
Setup
npm i @hyperserve/hyperserve-js and grab an API key from your dashboard. If you'd rather drop in a drag-and-drop widget, there's also a React Video Uploader (@hyperserve/video-uploader-react) that wraps this same create, upload, and complete flow.app/api/video/create/route.ts
tsximport { HyperserveClient } from "@hyperserve/hyperserve-js";
import { NextRequest, NextResponse } from "next/server";
// API key is server-side only, never expose it in the browser
const hyperserve = new HyperserveClient({
apiKey: process.env.HYPERSERVE_API_KEY,
});
export async function POST(req: NextRequest) {
const { filename, fileSizeBytes, dbVideoId } = await req.json();
const { id, uploadUrl, contentType } = await hyperserve.createVideo({
filename,
fileSizeBytes,
// Choose the resolutions you need for your use case
resolutions: ["720p", "1080p"],
isPublic: true,
// Attach your own data (e.g. your DB row id) to correlate later via webhooks
customMetadata: { dbVideoId },
});
return NextResponse.json({ id, uploadUrl, contentType });
}Client upload
tsx"use client";
import { putVideoToStorage } from "@hyperserve/hyperserve-js/browser";
async function uploadToHyperserve(file: File) {
// Your own id for this video, reused in your DB row below
const dbVideoId = crypto.randomUUID();
// 1. Ask your backend for an upload target
const { id, uploadUrl, contentType } = await fetch("/api/video/create", {
method: "POST",
body: JSON.stringify({
filename: file.name,
fileSizeBytes: file.size,
dbVideoId,
}),
}).then((r) => r.json());
// 2. Upload the bytes straight to Hyperserve storage
await putVideoToStorage({ uploadUrl, contentType, file });
// 3. Tell Hyperserve the upload is done so transcoding can start
await fetch("/api/video/complete", {
method: "POST",
body: JSON.stringify({ videoId: id }),
});
return { dbVideoId, hyperserveId: id };
}app/api/video/complete/route.ts
tsxconst { videoId } = await req.json();
await hyperserve.completeUpload(videoId);You can keep using your Supabase or Firebase database. Store Hyperserve's video ID in the same table or document from earlier, in place of the storage path. Hyperserve owns the file, the transcode, and delivery.
Supabase (PostgreSQL)
tsxawait supabase.from("videos").insert({
id: dbVideoId,
user_id,
hyperserve_id: hyperserveId, // no storage_path / public_url needed
mime_type: file.type,
size_bytes: file.size,
});Firebase (Firestore)
tsxawait setDoc(doc(db, "videos", dbVideoId), {
userId,
hyperserveId,
mimeType: file.type,
sizeBytes: file.size,
createdAt: Date.now(),
});Transcoding runs asynchronously. When it finishes, fetch the video to get a per-resolution playback URL (getVideo(hyperserveId) returns resolutions["720p"].videoUrl), or receive it via a webhook that stores it on your row, and drop it into the same <video> element from Section 5.
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 tricky to set up 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 help improve user experience by giving users the feeling that the interface is ready in real time while the video loads.
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.
Storing files directly in Supabase or Firebase (options A and B) is a fine MVP, but it leaves transcoding, performant playback, and scaling for you to build. Option C already offloads that to Hyperserve: we have done the hard work of building Video Architecture at Scale and wrapped it in a simple API. Sign up for a free account so adding video can be as easy as image uploads.
Can I use Hyperserve with Supabase?
Yes. Keep Supabase for auth and your Postgres videos table, and hand the video file itself to Hyperserve for transcoding and delivery. You store Hyperserve's video ID in the same table you'd otherwise use for a Supabase Storage path.
Can I use Hyperserve with Firebase?
Yes. Firebase Auth and Firestore stay exactly as they are, and you write the Hyperserve video ID into your Firestore videos document instead of a Firebase Storage path.
Do I still need a Supabase or Firebase database if I use Hyperserve?
Yes. Hyperserve owns video storage, transcoding, and delivery, but you still want your own database for users, permissions, and app data. Hyperserve slots in as the storage and delivery layer, not a replacement for Supabase or Firebase.
When should I use Hyperserve instead of Supabase or Firebase Storage?
Reach for Hyperserve when you need transcoding, multiple resolutions, or reliable playback at scale, which object storage alone doesn't give you. For a quick MVP with small clips, native Supabase or Firebase Storage is fine.
The rapid deployment video backend for modern devs
© 2026 Hyperserve. All rights reserved.
Made by Misty Mountain Software