Next.js File Upload

Complete File Upload System in Next.js: Images, PDFs & Videos

Every SaaS app needs file uploads — profile photos, invoices, product images, training videos. This guide builds a reusable upload module you can copy into any Next.js project: drag & drop, progress tracking, previews, validation, and storage on Supabase or AWS S3.

✓ Drag & Drop✓ Supabase + S3✓ Signed URLs✓ Multi-upload

One module, any project

Drop in UploadDropzone, wire up your storage provider, and ship file uploads in an afternoon.

What We're Building

A modular upload system split into small, testable pieces. The UI never talks to storage directly — everything goes through an API route and a storage service abstraction so you can swap Supabase for S3 without rewriting your components.

components/upload/
  UploadDropzone.tsx    # Drag & drop UI + previews
  useUpload.ts          # Upload state, progress, errors
lib/
  storage/
    types.ts            # Shared interfaces
    supabase.ts         # Supabase Storage adapter
    s3.ts               # AWS S3 adapter
    index.ts            # Provider switch
  validation/
    file.ts             # MIME, size, extension checks
app/api/
  upload/route.ts       # POST upload, DELETE file
  files/[id]/route.ts   # GET signed URL
Target audience

Beginner to intermediate developers who know React basics. No prior experience with cloud storage required — we cover setup for both Supabase and S3.

End result: a reusable UploadDropzone component you drop into any form, page, or admin panel. Pass a bucket prop, set your storage provider via env var, and you're done.

Database Schema

Store file metadata in your database. Never rely on storage alone — you need a record of who uploaded what, when, and where it lives.

create table files (
  id          uuid primary key default gen_random_uuid(),
  user_id     uuid references auth.users(id) on delete cascade,
  file_name   text not null,
  file_path   text not null,          -- storage key / path
  file_size   bigint not null,
  mime_type   text not null,
  provider    text not null           -- 'supabase' | 's3'
                check (provider in ('supabase', 's3')),
  bucket      text not null,
  public_url  text,                   -- null if private bucket
  created_at  timestamptz default now()
);

create index files_user_id_idx on files(user_id);
create index files_created_at_idx on files(created_at desc);

-- Row Level Security
alter table files enable row level security;

create policy "Users can read own files"
  on files for select
  using (auth.uid() = user_id);

create policy "Users can insert own files"
  on files for insert
  with check (auth.uid() = user_id);

create policy "Users can delete own files"
  on files for delete
  using (auth.uid() = user_id);

File Validation

Validate on the client for instant feedback, and again on the server because client checks can be bypassed. This module supports images, PDFs, and videos.

// lib/validation/file.ts

export const FILE_RULES = {
  image: {
    mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"],
    maxSizeMB: 5,
    extensions: [".jpg", ".jpeg", ".png", ".webp", ".gif"],
  },
  pdf: {
    mimeTypes: ["application/pdf"],
    maxSizeMB: 20,
    extensions: [".pdf"],
  },
  video: {
    mimeTypes: ["video/mp4", "video/webm", "video/quicktime"],
    maxSizeMB: 100,
    extensions: [".mp4", ".webm", ".mov"],
  },
} as const;

export type FileCategory = keyof typeof FILE_RULES;

export function validateFile(
  file: File,
  allowed: FileCategory[]
): { valid: true } | { valid: false; error: string } {
  const category = allowed.find((cat) =>
    FILE_RULES[cat].mimeTypes.includes(file.type)
  );

  if (!category) {
    return {
      valid: false,
      error: `File type not allowed. Accepted: ${allowed.join(", ")}`,
    };
  }

  const maxBytes = FILE_RULES[category].maxSizeMB * 1024 * 1024;
  if (file.size > maxBytes) {
    return {
      valid: false,
      error: `${file.name} exceeds ${FILE_RULES[category].maxSizeMB}MB limit`,
    };
  }

  const ext = "." + file.name.split(".").pop()?.toLowerCase();
  if (!FILE_RULES[category].extensions.includes(ext)) {
    return { valid: false, error: `Invalid extension for ${file.name}` };
  }

  return { valid: true };
}

export function isImage(mimeType: string) {
  return mimeType.startsWith("image/");
}

Storage Service

A single interface with two adapters. Switch providers with an environment variable — no component changes needed.

Shared types (lib/storage/types.ts)

export interface UploadResult {
  path: string;
  publicUrl: string | null;
  provider: "supabase" | "s3";
}

export interface StorageAdapter {
  upload(file: Buffer, path: string, mimeType: string): Promise<UploadResult>;
  delete(path: string): Promise<void>;
  getSignedUrl(path: string, expiresIn?: number): Promise<string>;
}

Provider switch (lib/storage/index.ts)

import { supabaseStorage } from "./supabase";
import { s3Storage } from "./s3";
import type { StorageAdapter } from "./types";

export function getStorageAdapter(): StorageAdapter {
  const provider = process.env.STORAGE_PROVIDER ?? "supabase";
  return provider === "s3" ? s3Storage : supabaseStorage;
}

export function buildFilePath(userId: string, fileName: string) {
  const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
  const timestamp = Date.now();
  return `${userId}/${timestamp}-${safe}`;
}

API Route (app/api/upload/route.ts)

Handles multipart uploads, validates files server-side, stores to cloud storage, and saves metadata to the database.

import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { getStorageAdapter, buildFilePath } from "@/lib/storage";
import { validateFile, type FileCategory } from "@/lib/validation/file";

const ALLOWED: FileCategory[] = ["image", "pdf", "video"];

export async function POST(request: NextRequest) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const formData = await request.formData();
  const file = formData.get("file") as File | null;
  const bucket = (formData.get("bucket") as string) || "uploads";

  if (!file) {
    return NextResponse.json({ error: "No file provided" }, { status: 400 });
  }

  const validation = validateFile(file, ALLOWED);
  if (!validation.valid) {
    return NextResponse.json({ error: validation.error }, { status: 400 });
  }

  const storage = getStorageAdapter();
  const filePath = buildFilePath(user.id, file.name);
  const buffer = Buffer.from(await file.arrayBuffer());

  const result = await storage.upload(buffer, filePath, file.type);

  const { data: record, error } = await supabase
    .from("files")
    .insert({
      user_id: user.id,
      file_name: file.name,
      file_path: result.path,
      file_size: file.size,
      mime_type: file.type,
      provider: result.provider,
      bucket,
      public_url: result.publicUrl,
    })
    .select()
    .single();

  if (error) {
    await storage.delete(result.path).catch(() => {});
    return NextResponse.json({ error: "Failed to save metadata" }, { status: 500 });
  }

  return NextResponse.json({ file: record });
}

export async function DELETE(request: NextRequest) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { fileId } = await request.json();

  const { data: file } = await supabase
    .from("files")
    .select("*")
    .eq("id", fileId)
    .eq("user_id", user.id)
    .single();

  if (!file) {
    return NextResponse.json({ error: "File not found" }, { status: 404 });
  }

  const storage = getStorageAdapter();
  await storage.delete(file.file_path);
  await supabase.from("files").delete().eq("id", fileId);

  return NextResponse.json({ success: true });
}

useUpload Hook (components/upload/useUpload.ts)

Manages upload state, per-file progress, errors, and the list of completed uploads. Uses XMLHttpRequest for real progress events — fetch doesn't expose upload progress.

"use client";

import { useCallback, useState } from "react";
import { validateFile, type FileCategory } from "@/lib/validation/file";

export type UploadItem = {
  id: string;
  file: File;
  progress: number;
  status: "pending" | "uploading" | "done" | "error";
  error?: string;
  result?: { file: Record<string, unknown> };
  preview?: string;
};

type UseUploadOptions = {
  bucket?: string;
  allowed: FileCategory[];
  multiple?: boolean;
  maxFiles?: number;
  onComplete?: (files: UploadItem[]) => void;
};

export function useUpload({
  bucket = "uploads",
  allowed,
  multiple = true,
  maxFiles = 10,
  onComplete,
}: UseUploadOptions) {
  const [items, setItems] = useState<UploadItem[]>([]);

  const updateItem = (id: string, patch: Partial<UploadItem>) => {
    setItems((prev) =>
      prev.map((item) => (item.id === id ? { ...item, ...patch } : item))
    );
  };

  const uploadFile = (item: UploadItem): Promise<void> => {
    return new Promise((resolve) => {
      const xhr = new XMLHttpRequest();
      const formData = new FormData();
      formData.append("file", item.file);
      formData.append("bucket", bucket);

      xhr.upload.addEventListener("progress", (e) => {
        if (e.lengthComputable) {
          const progress = Math.round((e.loaded / e.total) * 100);
          updateItem(item.id, { progress, status: "uploading" });
        }
      });

      xhr.addEventListener("load", () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          const result = JSON.parse(xhr.responseText);
          updateItem(item.id, { progress: 100, status: "done", result });
        } else {
          const err = JSON.parse(xhr.responseText);
          updateItem(item.id, {
            status: "error",
            error: err.error ?? "Upload failed",
          });
        }
        resolve();
      });

      xhr.addEventListener("error", () => {
        updateItem(item.id, { status: "error", error: "Network error" });
        resolve();
      });

      xhr.open("POST", "/api/upload");
      xhr.send(formData);
    });
  };

  const addFiles = useCallback(
    async (fileList: FileList | File[]) => {
      const incoming = Array.from(fileList);
      const remaining = maxFiles - items.filter((i) => i.status !== "error").length;

      if (incoming.length > remaining) {
        alert(`Maximum ${maxFiles} files allowed`);
        return;
      }

      const newItems: UploadItem[] = incoming.map((file) => {
        const validation = validateFile(file, allowed);
        const preview = file.type.startsWith("image/")
          ? URL.createObjectURL(file)
          : undefined;

        return {
          id: crypto.randomUUID(),
          file,
          progress: 0,
          status: validation.valid ? "pending" : "error",
          error: validation.valid ? undefined : validation.error,
          preview,
        };
      });

      setItems((prev) => [...prev, ...newItems]);

      const toUpload = newItems.filter((i) => i.status === "pending");
      await Promise.all(toUpload.map(uploadFile));

      setItems((current) => {
        onComplete?.(current.filter((i) => i.status === "done"));
        return current;
      });
    },
    [allowed, bucket, items, maxFiles, onComplete]
  );

  const removeFile = async (id: string) => {
    const item = items.find((i) => i.id === id);
    if (item?.preview) URL.revokeObjectURL(item.preview);

    if (item?.status === "done" && item.result?.file) {
      const fileId = (item.result.file as { id: string }).id;
      await fetch("/api/upload", {
        method: "DELETE",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ fileId }),
      });
    }

    setItems((prev) => prev.filter((i) => i.id !== id));
  };

  const clearAll = () => {
    items.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
    setItems([]);
  };

  return { items, addFiles, removeFile, clearAll, isUploading: items.some((i) => i.status === "uploading") };
}

UploadDropzone Component

The reusable UI: drag & drop zone, file picker fallback, per-file progress bars, image thumbnails, and remove buttons.

"use client";

import { useCallback, useRef, useState } from "react";
import { useUpload, type UploadItem } from "./useUpload";
import type { FileCategory } from "@/lib/validation/file";

type Props = {
  bucket?: string;
  allowed?: FileCategory[];
  multiple?: boolean;
  maxFiles?: number;
  onComplete?: (files: UploadItem[]) => void;
  className?: string;
};

export function UploadDropzone({
  bucket = "uploads",
  allowed = ["image", "pdf", "video"],
  multiple = true,
  maxFiles = 10,
  onComplete,
  className = "",
}: Props) {
  const inputRef = useRef<HTMLInputElement>(null);
  const [isDragging, setIsDragging] = useState(false);
  const { items, addFiles, removeFile, isUploading } = useUpload({
    bucket,
    allowed,
    multiple,
    maxFiles,
    onComplete,
  });

  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      setIsDragging(false);
      if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
    },
    [addFiles]
  );

  const accept = allowed
    .flatMap((cat) => {
      const map: Record<FileCategory, string> = {
        image: "image/*",
        pdf: "application/pdf",
        video: "video/*",
      };
      return map[cat];
    })
    .join(",");

  return (
    <div className={className}>
      <div
        role="button"
        tabIndex={0}
        onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
        onDragLeave={() => setIsDragging(false)}
        onDrop={handleDrop}
        onClick={() => inputRef.current?.click()}
        onKeyDown={(e) => e.key === "Enter" && inputRef.current?.click()}
        className={`dropzone ${isDragging ? "dropzone--active" : ""}`}
      >
        <input
          ref={inputRef}
          type="file"
          accept={accept}
          multiple={multiple}
          hidden
          onChange={(e) => e.target.files && addFiles(e.target.files)}
        />
        <p className="dropzone__title">
          {isDragging ? "Drop files here" : "Drag & drop files, or click to browse"}
        </p>
        <p className="dropzone__hint">
          Images (5MB) · PDFs (20MB) · Videos (100MB)
        </p>
      </div>

      {items.length > 0 && (
        <ul className="upload-list">
          {items.map((item) => (
            <li key={item.id} className="upload-item">
              {item.preview ? (
                <img src={item.preview} alt="" className="upload-item__thumb" />
              ) : (
                <span className="upload-item__icon">
                  {item.file.type.includes("pdf") ? "📄" : "🎬"}
                </span>
              )}
              <div className="upload-item__info">
                <span className="upload-item__name">{item.file.name}</span>
                {item.status === "uploading" && (
                  <div className="progress-bar">
                    <div
                      className="progress-bar__fill"
                      style={{ width: `${item.progress}%` }}
                    />
                  </div>
                )}
                {item.status === "error" && (
                  <span className="upload-item__error">{item.error}</span>
                )}
                {item.status === "done" && (
                  <span className="upload-item__done">✓ Uploaded</span>
                )}
              </div>
              <button
                type="button"
                onClick={() => removeFile(item.id)}
                disabled={isUploading && item.status === "uploading"}
                aria-label="Remove file"
              >
                ✕
              </button>
            </li>
          ))}
        </ul>
      )}

      <style jsx>{`
        .dropzone {
          border: 2px dashed #cbd5e1;
          border-radius: 16px;
          padding: 40px 24px;
          text-align: center;
          cursor: pointer;
          transition: border-color 0.2s, background 0.2s;
        }
        .dropzone:hover, .dropzone--active {
          border-color: #6366f1;
          background: #f5f3ff;
        }
        .dropzone__title { font-weight: 600; color: #0f172a; margin-bottom: 6px; }
        .dropzone__hint { font-size: 13px; color: #64748b; }
        .upload-list { list-style: none; margin-top: 16px; padding: 0; }
        .upload-item {
          display: flex; align-items: center; gap: 12px;
          padding: 10px; border: 1px solid #e2e8f0; border-radius: 10px; margin-bottom: 8px;
        }
        .upload-item__thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 8px; }
        .upload-item__icon { font-size: 28px; width: 48px; text-align: center; }
        .upload-item__info { flex: 1; min-width: 0; }
        .upload-item__name {
          display: block; font-size: 14px; font-weight: 500;
          white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .progress-bar {
          height: 4px; background: #e2e8f0; border-radius: 2px; margin-top: 6px; overflow: hidden;
        }
        .progress-bar__fill {
          height: 100%; background: #6366f1; border-radius: 2px; transition: width 0.2s;
        }
        .upload-item__error { font-size: 12px; color: #dc2626; }
        .upload-item__done { font-size: 12px; color: #16a34a; }
      `}</style>
    </div>
  );
}

Supabase Storage Adapter

Supabase Storage is the fastest path for teams already using Supabase Auth. Create a bucket in the Dashboard and set policies for authenticated uploads.

// lib/storage/supabase.ts
import { createClient } from "@/lib/supabase/server";
import type { StorageAdapter, UploadResult } from "./types";

const BUCKET = process.env.SUPABASE_STORAGE_BUCKET ?? "uploads";

export const supabaseStorage: StorageAdapter = {
  async upload(file, path, mimeType) {
    const supabase = await createClient();
    const { error } = await supabase.storage
      .from(BUCKET)
      .upload(path, file, { contentType: mimeType, upsert: false });

    if (error) throw new Error(error.message);

    const { data } = supabase.storage.from(BUCKET).getPublicUrl(path);

    return {
      path,
      publicUrl: data.publicUrl,
      provider: "supabase",
    } satisfies UploadResult;
  },

  async delete(path) {
    const supabase = await createClient();
    const { error } = await supabase.storage.from(BUCKET).remove([path]);
    if (error) throw new Error(error.message);
  },

  async getSignedUrl(path, expiresIn = 3600) {
    const supabase = await createClient();
    const { data, error } = await supabase.storage
      .from(BUCKET)
      .createSignedUrl(path, expiresIn);
    if (error) throw new Error(error.message);
    return data.signedUrl;
  },
};

Supabase bucket policy for authenticated uploads:

-- Allow authenticated users to upload to their own folder
create policy "Users upload own files"
  on storage.objects for insert
  with check (
    bucket_id = 'uploads'
    and auth.uid()::text = (storage.foldername(name))[1]
  );

create policy "Users read own files"
  on storage.objects for select
  using (
    bucket_id = 'uploads'
    and auth.uid()::text = (storage.foldername(name))[1]
  );

AWS S3 Adapter

For teams on AWS or needing more control over CDN, lifecycle rules, and cross-region replication. Install the AWS SDK and set credentials via env vars.

npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/storage/s3.ts
import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
  GetObjectCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { StorageAdapter } from "./types";

const client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

const BUCKET = process.env.AWS_S3_BUCKET!;

export const s3Storage: StorageAdapter = {
  async upload(file, path, mimeType) {
    await client.send(
      new PutObjectCommand({
        Bucket: BUCKET,
        Key: path,
        Body: file,
        ContentType: mimeType,
      })
    );

    const publicUrl = process.env.AWS_CLOUDFRONT_URL
      ? `${process.env.AWS_CLOUDFRONT_URL}/${path}`
      : null;

    return { path, publicUrl, provider: "s3" };
  },

  async delete(path) {
    await client.send(
      new DeleteObjectCommand({ Bucket: BUCKET, Key: path })
    );
  },

  async getSignedUrl(path, expiresIn = 3600) {
    const command = new GetObjectCommand({ Bucket: BUCKET, Key: path });
    return getSignedUrl(client, command, { expiresIn });
  },
};

Environment variables for S3:

STORAGE_PROVIDER=s3
AWS_REGION=us-east-1
AWS_S3_BUCKET=my-app-uploads
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_CLOUDFRONT_URL=https://d1234.cloudfront.net  # optional

Signed URLs for Private Files

Public buckets expose files to anyone with the URL. For invoices, contracts, or private media, serve files through time-limited signed URLs instead.

// app/api/files/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { getStorageAdapter } from "@/lib/storage";

export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { data: file } = await supabase
    .from("files")
    .select("*")
    .eq("id", id)
    .eq("user_id", user.id)
    .single();

  if (!file) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  // Public files can return the stored URL directly
  if (file.public_url) {
    return NextResponse.json({ url: file.public_url });
  }

  const storage = getStorageAdapter();
  const signedUrl = await storage.getSignedUrl(file.file_path, 3600);

  return NextResponse.json({ url: signedUrl, expiresIn: 3600 });
}

Client-side usage:

async function openPrivateFile(fileId: string) {
  const res = await fetch(`/api/files/${fileId}`);
  const { url } = await res.json();
  window.open(url, "_blank");
}

Delete Files

Deletion is a two-step operation: remove from storage, then remove the database record. The DELETE handler in the API route (shown above) does both. If storage deletion fails, don't delete the DB record — log the error and retry later.

Orphan cleanup: run a weekly cron job that finds database records where storage objects no longer exist (or vice versa) and reconciles them. This prevents billing surprises from ghost files in S3.

Usage Example — Drop Into Any Page

That's the entire module. Here's how you use it in a product form, profile settings page, or admin panel.

// app/dashboard/documents/page.tsx
"use client";

import { UploadDropzone } from "@/components/upload/UploadDropzone";

export default function DocumentsPage() {
  return (
    <div>
      <h1>Upload Documents</h1>

      <UploadDropzone
        bucket="documents"
        allowed={["image", "pdf"]}
        multiple
        maxFiles={5}
        onComplete={(files) => {
          console.log("Uploaded:", files.map((f) => f.result?.file));
        }}
      />

      <h2 style={{ marginTop: 32 }}>Upload Training Videos</h2>

      <UploadDropzone
        bucket="videos"
        allowed={["video"]}
        multiple={false}
        maxFiles={1}
      />
    </div>
  );
}

What you get

  • Drag & drop + click to browse
  • Per-file progress bars
  • Image thumbnail previews
  • Client + server validation
  • Multiple simultaneous uploads

Storage features

  • Supabase Storage adapter
  • AWS S3 adapter
  • Signed URLs for private files
  • Delete from storage + database
  • Provider switch via env var

Common Pitfalls

Uploading directly from the browser to S3

Exposes credentials or requires overly permissive bucket policies. Always upload through your API route or use pre-signed POST URLs generated server-side.

Skipping server-side validation

Users can bypass client checks with curl. Validate MIME type, size, and extension on the server every time.

Not revoking object URLs

URL.createObjectURL leaks memory if you don't call revokeObjectURL when removing previews.

Storing files without metadata

Storage alone has no ownership model. Always save a database record with user_id, path, and mime_type.

Large video uploads through serverless

Next.js API routes have body size limits (default 4MB on Vercel). For large videos, generate a pre-signed URL and upload directly from the client to S3/Supabase.

Public buckets for sensitive files

Invoices and contracts should use private buckets with signed URLs, not public URLs anyone can guess.

Need a production file upload system?

We build upload pipelines for SaaS products — image optimization, virus scanning, CDN delivery, and storage cost optimization included.

Request File Upload Consultation

Shares
Get Quote
Let's build something powerful

Have a project idea? Let’s turn it into a scalable product.

Book Free Consultation

© 2026 Endurance Softwares. All rights reserved.