How to Add Video Uploads to Your Next.js App (Supabase or Firebase)

RT

Ryan Trann

AUTHOR

February 7, 2026

7 min read

Video

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 upload to Next.js app

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?

Check out the initial setup guides to get your project ready.
  • Supabase: Getting Started
  • Firebase: Web Setup

1. The MVP Video Upload Pipeline

To add video to your app MVP, you need a strong user-generated content (UGC) video flow. This flow requires five key parts:

  1. A database table to store information about uploaded videos
  2. A REST API or signed upload urls to upload videos
  3. A storage bucket to persist the uploaded videos
  4. A content delivery network (CDN) to make the video files globally available
  5. A user interface (UI) to display the file via the HTML5 <video> element

2. Store Video Metadata in Your Database

Databases 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.

A. Supabase (PostgreSQL)

Create videos table

sql
create 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()
);

B. Firebase (Firestore)

Firestore document creation

tsx
import { 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(),
});

3. Storage Buckets + CDN for Video Hosting

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.

Table stakes features (Supabase and Firebase)

Both Supabase Storage and Firebase Storage provide the core features for video uploads:

  • File storage behind a global CDN
  • Signed upload URLs
  • Public, cacheable URLs for playback
  • Throughput suitable for large video files

If you're hosting and serving videos, either option can work for you.

Where they differ

  • Supabase Storage (Amazon S3): Uploads can be client-side or driven via signed URLs from your backend, so video API or server-orchestrated video pipelines are potentially better suited for this option.
  • Firebase Storage: Optimized for client-side usage. Its SDKs allow resumable uploads and automatic retries, which makes it a strong fit for mobile apps or weak network conditions.

The right choice depends less on "CDN features" and more on whether your video pipeline is backend driven or client driven.

4. Implementing Next.js Video Uploads to Your Storage Bucket

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.

A. Supabase Upload Flow

Option 1: Direct POST to a Next.js API Route (simple, good for <100MB)

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

tsx
import { 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 });
}

Option 2: Signed Upload URL (recommended for larger files)

API route for signed URL

tsx
export 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

tsx
await fetch(uploadUrl, { method: "PUT", body: file });

Insert metadata after upload

tsx
await 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,
});

B. Firebase Video Upload Flow

Firebase handles everything client-side.

Client upload (Firebase Storage)

tsx
import { 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(),
  });
});

C. Offloading Video to Hyperserve (Supabase or Firebase)

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

Install the Hyperserve SDK with 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.

Step 1: Create the upload from your backend

app/api/video/create/route.ts

tsx
import { 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 });
}

Step 2: Upload from the browser, then confirm

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

tsx
const { videoId } = await req.json();
await hyperserve.completeUpload(videoId);

Step 3: Store the Hyperserve ID in your database

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)

tsx
await 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)

tsx
await 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.

5. Displaying Video in Your App

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
/>

6. Extra Steps You Shouldn't Skip

Limit file size before uploading

File size validation

tsx
if (file.size > 200 * 1024 * 1024) {
  alert("Max size is 200MB");
  return;
}

Validate MIME type before uploading

MIME type validation

tsx
if (!file.type.startsWith("video/")) {
  alert("File must be a video");
  return;
}

Generate a thumbnail (optional)

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.

7. Full Architecture Summary

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.

Frequently Asked Questions

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.

Add video to your app

Hyperserve handles upload, transcoding, storage, and delivery — you just call the API.

Hyperserve

The rapid deployment video backend for modern devs

Product

FeaturesPricingBlog

Social

YouTubeGitHub

© 2026 Hyperserve. All rights reserved.

Made by Misty Mountain Software