Overview
A complete file upload solution with Convex storage backend. Features include:
- Drag-and-drop file selection
- Click to browse fallback
- File type validation
- File size limits
- Upload progress tracking
- Image previews
- Error handling
- Demo mode support
Installation
npx shadcn@latest add https://convex-ui.vercel.app/r/dropzone-nextjs
This installs:
Dropzone component
useConvexUpload hook
- Complete Convex backend with file storage
- File management functions
Usage
Basic Example
"use client";
import { Dropzone } from "@/components/dropzone";
import { useConvexUpload } from "@/hooks/use-convex-upload";
export default function UploadPage() {
const upload = useConvexUpload();
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Upload Files</h1>
<Dropzone upload={upload} />
</div>
);
}
With File Type Restrictions
const upload = useConvexUpload({
allowedMimeTypes: ["image/*", "application/pdf"],
maxFileSize: 5 * 1024 * 1024, // 5MB
maxFiles: 3,
});
return <Dropzone upload={upload} />;
Images Only
const upload = useConvexUpload({
allowedMimeTypes: ["image/png", "image/jpeg", "image/gif"],
maxFileSize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
});
return <Dropzone upload={upload} />;
Hook API
useConvexUpload
Manages file upload state and operations:
const upload = useConvexUpload(options);
Options:
Array of allowed MIME types. Supports wildcards (e.g., "image/*").Empty array = all types allowed.Examples:
["image/*"] - All images
["image/png", "image/jpeg"] - Only PNG and JPEG
["application/pdf"] - Only PDF files
Maximum file size in bytes.Examples:
1024 * 1024 - 1MB
5 * 1024 * 1024 - 5MB
50 * 1024 * 1024 - 50MB
Maximum number of files that can be uploaded at once.
Return Value:
interface UseConvexUploadReturn {
// File management
files: FileWithPreview[];
setFiles: (files: FileWithPreview[]) => void;
// Upload state
loading: boolean;
errors: { name: string; message: string }[];
successes: string[];
isSuccess: boolean;
// Actions
onUpload: () => Promise<void>;
// Configuration
maxFileSize: number;
maxFiles: number;
allowedMimeTypes: string[];
// React Dropzone props
getRootProps: () => any;
getInputProps: () => any;
isDragActive: boolean;
open: () => void;
}
Component Source
The Dropzone component provides a complete UI:
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Upload, X, CheckCircle, AlertCircle } from "lucide-react";
import { type UseConvexUploadReturn } from "../hooks/use-convex-upload";
interface DropzoneProps {
upload: UseConvexUploadReturn;
className?: string;
}
export function Dropzone({ upload, className }: DropzoneProps) {
const {
files,
setFiles,
loading,
errors,
successes,
isSuccess,
onUpload,
maxFileSize,
maxFiles,
getRootProps,
getInputProps,
isDragActive,
open,
} = upload;
const removeFile = (fileName: string) => {
setFiles(files.filter((f) => f.name !== fileName));
};
return (
<div className={`space-y-4 ${className ?? ""}`}>
{/* Dropzone area */}
<Card
{...getRootProps()}
className={`border-2 border-dashed cursor-pointer transition-all ${
isDragActive ? "border-primary bg-primary/10" : ""
}`}
>
<CardContent className="p-12 text-center">
<input {...getInputProps()} />
<Upload className="h-8 w-8 mx-auto mb-4 text-muted-foreground" />
<p className="text-base font-semibold">
{isDragActive ? "Drop files here" : "Drag and drop files"}
</p>
<p className="text-sm text-muted-foreground">
or <button onClick={open}>click to browse</button>
</p>
<Badge variant="secondary" className="mt-4">
Max {maxFiles} files • Up to {formatBytes(maxFileSize)} each
</Badge>
</CardContent>
</Card>
{/* File list */}
{files.length > 0 && (
<div className="space-y-3">
{files.map((file) => (
<FileItem
key={file.name}
file={file}
onRemove={removeFile}
error={errors.find((e) => e.name === file.name)}
isUploaded={successes.includes(file.name)}
disabled={loading}
/>
))}
</div>
)}
{/* Upload button */}
{files.length > 0 && (
<Button
onClick={onUpload}
disabled={!canUpload}
size="lg"
className="w-full"
>
{loading ? (
<>Uploading {files.length} file(s)...</>
) : isSuccess ? (
<>All files uploaded!</>
) : (
<>Upload {files.length} file(s)</>
)}
</Button>
)}
</div>
);
}
File Validation
The hook validates files on drop:
// File type validation
if (allowedMimeTypes.length > 0) {
const isValid = allowedMimeTypes.some((type) => {
if (type.endsWith("/*")) {
return file.type.startsWith(type.replace("/*", ""));
}
return file.type === type;
});
}
// File size validation
if (file.size > maxFileSize) {
file.errors.push({
code: "file-too-large",
message: `File is larger than ${formatBytes(maxFileSize)}`,
});
}
// File count validation
if (files.length >= maxFiles) {
file.errors.push({
code: "too-many-files",
message: `Maximum ${maxFiles} files allowed`,
});
}
Upload Process
The upload happens in two steps:
1. Generate Upload URL
const uploadUrl = await generateUploadUrl();
2. Upload to Convex Storage
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const { storageId } = await response.json();
await saveFile({
storageId,
name: file.name,
type: file.type,
size: file.size,
});
Backend Implementation
File Schema
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
files: defineTable({
storageId: v.id("_storage"),
userId: v.optional(v.id("users")),
sessionId: v.optional(v.string()),
name: v.string(),
type: v.string(),
size: v.number(),
})
.index("by_user", ["userId"])
.index("by_session", ["sessionId"]),
});
File Functions
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
// Generate upload URL
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
// Save file metadata
export const saveFile = mutation({
args: {
storageId: v.id("_storage"),
name: v.string(),
type: v.string(),
size: v.number(),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Validate file size
if (args.size > MAX_FILE_SIZE) {
throw new Error(`File too large. Maximum ${MAX_FILE_SIZE / 1024 / 1024}MB.`);
}
// Get user ID if authenticated
let userId = undefined;
try {
const { getAuthUserId } = await import("@convex-dev/auth/server");
userId = (await getAuthUserId(ctx)) ?? undefined;
} catch {
// Auth not configured
}
return await ctx.db.insert("files", {
storageId: args.storageId,
userId,
sessionId: args.sessionId,
name: args.name.trim().slice(0, 255),
type: args.type,
size: args.size,
});
},
});
// Get download URL
export const getUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
// List user's files
export const list = query({
args: { sessionId: v.optional(v.string()) },
handler: async (ctx, args) => {
let userId = null;
try {
const { getAuthUserId } = await import("@convex-dev/auth/server");
userId = await getAuthUserId(ctx);
} catch {}
let files;
if (userId) {
files = await ctx.db
.query("files")
.withIndex("by_user", (q) => q.eq("userId", userId))
.collect();
} else if (args.sessionId) {
files = await ctx.db
.query("files")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();
} else {
return [];
}
return await Promise.all(
files.map(async (file) => ({
...file,
url: await ctx.storage.getUrl(file.storageId),
}))
);
},
});
// Delete file
export const remove = mutation({
args: {
fileId: v.id("files"),
sessionId: v.optional(v.string()),
},
handler: async (ctx, args) => {
const file = await ctx.db.get(args.fileId);
if (!file) return null;
let userId = null;
try {
const { getAuthUserId } = await import("@convex-dev/auth/server");
userId = await getAuthUserId(ctx);
} catch {}
const canDelete =
!file.userId ||
file.userId === userId ||
(file.sessionId && file.sessionId === args.sessionId);
if (canDelete) {
await ctx.storage.delete(file.storageId);
await ctx.db.delete(args.fileId);
}
return null;
},
});
Features
Image Previews
Automatic preview generation for images:
if (file.type.startsWith("image/")) {
file.preview = URL.createObjectURL(file);
}
File Icons
Different icons based on file type:
function getFileIcon(type: string) {
if (type.startsWith("image/")) return ImageIcon;
if (type.startsWith("video/")) return FileVideoIcon;
if (type.startsWith("audio/")) return FileAudioIcon;
if (type.startsWith("text/")) return FileTextIcon;
return FileIcon;
}
Upload Progress
Visual feedback during upload:
- Loading spinner on upload button
- Disabled state during upload
- Success/error badges on files
Error Handling
Granular error tracking:
interface FileError {
name: string;
message: string;
}
// Errors tracked per file
const errors: FileError[] = [
{ name: "document.pdf", message: "File too large" },
{ name: "image.png", message: "Upload failed" },
];
Common Patterns
Profile Picture Upload
const upload = useConvexUpload({
allowedMimeTypes: ["image/jpeg", "image/png"],
maxFileSize: 2 * 1024 * 1024, // 2MB
maxFiles: 1,
});
return (
<div className="max-w-md">
<h2>Upload Profile Picture</h2>
<Dropzone upload={upload} />
</div>
);
Document Upload
const upload = useConvexUpload({
allowedMimeTypes: ["application/pdf", "application/msword"],
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
});
return <Dropzone upload={upload} className="max-w-3xl" />;
const upload = useConvexUpload({
allowedMimeTypes: ["image/*", "video/*"],
maxFileSize: 50 * 1024 * 1024,
maxFiles: 10,
});
return (
<div>
<Dropzone upload={upload} />
{upload.isSuccess && (
<div className="mt-8">
<h3>Uploaded Files</h3>
<FileList />
</div>
)}
</div>
);
With Upload Callback
const upload = useConvexUpload();
const handleUpload = async () => {
await upload.onUpload();
if (upload.isSuccess) {
console.log("All files uploaded!");
// Redirect or show success message
}
};
return (
<div>
<Dropzone upload={upload} />
<Button onClick={handleUpload}>Custom Upload</Button>
</div>
);
The component includes a helper for displaying file sizes:
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
// Examples:
formatBytes(1024); // "1 KB"
formatBytes(1048576); // "1 MB"
formatBytes(5242880); // "5 MB"
formatBytes(52428800); // "50 MB"
Security
File Size Limits
Enforce maximum file size on both client and server:
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
MIME Type Validation
Validate file types before upload:
allowedMimeTypes: ["image/png", "image/jpeg"]
User Association
Files are associated with authenticated users or sessions:
{
storageId: "...",
userId: "j1234567890", // If authenticated
sessionId: "demo-abc-123", // If demo mode
}
Styling
Customize the dropzone appearance:
<Dropzone
upload={upload}
className="border-2 border-dashed border-gray-300 rounded-lg"
/>
The component uses shadcn/ui components and Tailwind CSS for styling.