JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
PreviousNext

The Ultimate Guide to File Uploads in Next.js - S3, Presigned URLs & Dropzone

Master file uploads in Next.js with AWS S3 and Cloudflare R2 using presigned URLs. Complete guide with reusable dropzone component, progress tracking, and production-ready code examples for secure, scalable file management.

The Ultimate Guide to File Uploads in Next.js (S3, Presigned URLs, Dropzone)

This comprehensive guide covers implementing file uploads in Next.js using both AWS S3 and Cloudflare R2 with presigned URLs, featuring a reusable dropzone component powered by react-dropzone.

Table of Contents

  1. Reusable Dropzone Component
  2. AWS S3 Implementation
  3. Cloudflare R2 Implementation
  4. Complete Demo Page

1. Reusable Dropzone Component

Installation

First, install the required dependencies:

pnpm add react-dropzone uuid sonner lucide-react
npm install -D @types/uuid

Base Dropzone Component

// components/ui/dropzone.tsx
"use client";
 
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { FileRejection, useDropzone } from "react-dropzone";
import { useCallback, useState, useEffect } from "react";
import { toast } from "sonner";
import { v4 as uuidv4 } from "uuid";
import { Loader2, Trash2, Upload, File, Image } from "lucide-react";
 
export interface FileWithMetadata {
  id: string;
  file: File;
  uploading: boolean;
  progress: number;
  key?: string;
  publicUrl?: string;
  isDeleting: boolean;
  error: boolean;
  objectUrl?: string;
}
 
interface DropzoneProps {
  provider: "aws-s3" | "cloudflare-r2";
  variant?: "default" | "compact";
  accept?: Record<string, string[]>;
  maxFiles?: number;
  maxSize?: number;
  onFilesChange?: (files: FileWithMetadata[]) => void;
  className?: string;
}
 
export function Dropzone({
  provider,
  variant = "default",
  accept = {
    "image/*": [],
    "application/pdf": [],
    "application/msword": [],
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
      [],
    "application/zip": [],
  },
  maxFiles = 5,
  maxSize = 1024 * 1024 * 10, // 10MB
  onFilesChange,
  className,
}: DropzoneProps) {
  const [files, setFiles] = useState<FileWithMetadata[]>([]);
 
  useEffect(() => {
    onFilesChange?.(files);
  }, [files, onFilesChange]);
 
  const removeFile = async (fileId: string) => {
    try {
      const fileToRemove = files.find((f) => f.id === fileId);
      if (!fileToRemove) return;
 
      if (fileToRemove.objectUrl) {
        URL.revokeObjectURL(fileToRemove.objectUrl);
      }
 
      setFiles((prevFiles) =>
        prevFiles.map((f) => (f.id === fileId ? { ...f, isDeleting: true } : f))
      );
 
      const endpoint =
        provider === "aws-s3" ? "/api/s3/delete" : "/api/r2/delete";
      const response = await fetch(endpoint, {
        method: "DELETE",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ key: fileToRemove.key }),
      });
 
      if (!response.ok) {
        toast.error("Failed to delete file");
        setFiles((prevFiles) =>
          prevFiles.map((f) =>
            f.id === fileId ? { ...f, isDeleting: false, error: true } : f
          )
        );
        return;
      }
 
      setFiles((prevFiles) => prevFiles.filter((f) => f.id !== fileId));
      toast.success("File deleted successfully");
    } catch (error) {
      toast.error("Failed to delete file");
      setFiles((prevFiles) =>
        prevFiles.map((f) =>
          f.id === fileId ? { ...f, isDeleting: false, error: true } : f
        )
      );
    }
  };
 
  const uploadFile = async (file: File) => {
    setFiles((prevFiles) =>
      prevFiles.map((f) => (f.file === file ? { ...f, uploading: true } : f))
    );
 
    try {
      const endpoint =
        provider === "aws-s3" ? "/api/s3/upload" : "/api/r2/upload";
      const presignedResponse = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
          size: file.size,
        }),
      });
 
      if (!presignedResponse.ok) {
        toast.error("Failed to get presigned URL");
        setFiles((prevFiles) =>
          prevFiles.map((f) =>
            f.file === file
              ? { ...f, uploading: false, progress: 0, error: true }
              : f
          )
        );
        return;
      }
 
      const { presignedUrl, key, publicUrl } = await presignedResponse.json();
 
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
 
        xhr.upload.onprogress = (event) => {
          if (event.lengthComputable) {
            const percentComplete = (event.loaded / event.total) * 100;
            setFiles((prevFiles) =>
              prevFiles.map((f) =>
                f.file === file
                  ? {
                      ...f,
                      progress: Math.round(percentComplete),
                      key: key,
                      publicUrl: publicUrl,
                    }
                  : f
              )
            );
          }
        };
 
        xhr.onload = () => {
          if (xhr.status === 200 || xhr.status === 204) {
            setFiles((prevFiles) =>
              prevFiles.map((f) =>
                f.file === file
                  ? { ...f, progress: 100, uploading: false, error: false }
                  : f
              )
            );
            toast.success("File uploaded successfully");
            resolve();
          } else {
            reject(new Error(`Upload failed with status: ${xhr.status}`));
          }
        };
 
        xhr.onerror = () => {
          reject(new Error("Upload failed"));
        };
 
        xhr.open("PUT", presignedUrl);
        xhr.setRequestHeader("Content-Type", file.type);
        xhr.send(file);
      });
    } catch (error) {
      toast.error("Upload failed");
      setFiles((prevFiles) =>
        prevFiles.map((f) =>
          f.file === file
            ? { ...f, uploading: false, progress: 0, error: true }
            : f
        )
      );
    }
  };
 
  const onDrop = useCallback((acceptedFiles: File[]) => {
    if (acceptedFiles.length) {
      setFiles((prevFiles) => [
        ...prevFiles,
        ...acceptedFiles.map((file) => ({
          id: uuidv4(),
          file,
          uploading: false,
          progress: 0,
          isDeleting: false,
          error: false,
          objectUrl: URL.createObjectURL(file),
        })),
      ]);
 
      acceptedFiles.forEach(uploadFile);
    }
  }, []);
 
  const onDropRejected = useCallback(
    (fileRejections: FileRejection[]) => {
      if (fileRejections.length) {
        const tooManyFiles = fileRejections.find(
          (rejection) => rejection.errors[0].code === "too-many-files"
        );
        const fileTooLarge = fileRejections.find(
          (rejection) => rejection.errors[0].code === "file-too-large"
        );
 
        if (tooManyFiles) {
          toast.error(`Too many files. Maximum ${maxFiles} files allowed.`);
        }
        if (fileTooLarge) {
          toast.error(
            `File too large. Maximum ${maxSize / (1024 * 1024)}MB allowed.`
          );
        }
      }
    },
    [maxFiles, maxSize]
  );
 
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    onDropRejected,
    maxFiles,
    maxSize,
    accept,
  });
 
  const isCompact = variant === "compact";
 
  return (
    <div className={cn("w-full", className)}>
      <Card
        {...getRootProps()}
        className={cn(
          "relative border-2 border-dashed transition-colors duration-200 ease-in-out cursor-pointer",
          isCompact ? "h-32" : "h-64",
          isDragActive
            ? "border-primary bg-primary/10 border-solid"
            : "border-border hover:border-primary"
        )}
      >
        <CardContent className="flex items-center justify-center h-full w-full">
          <input {...getInputProps()} />
          {isDragActive ? (
            <div className="flex flex-col items-center gap-2">
              <Upload className="h-8 w-8 text-primary" />
              <p className="text-center">Drop the files here...</p>
            </div>
          ) : (
            <div className="flex flex-col items-center gap-3">
              <div className="flex gap-2">
                <Image className="h-6 w-6 text-muted-foreground" />
                <File className="h-6 w-6 text-muted-foreground" />
              </div>
              <p className="text-center text-sm text-muted-foreground">
                Drag & drop files here, or click to select
              </p>
              <Button size={isCompact ? "sm" : "default"}>Select Files</Button>
            </div>
          )}
        </CardContent>
      </Card>
 
      {files.length > 0 && (
        <div className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
          {files.map(
            ({
              id,
              file,
              uploading,
              progress,
              isDeleting,
              error,
              objectUrl,
            }) => {
              const isImage = file.type.startsWith("image/");
 
              return (
                <div key={id} className="flex flex-col gap-1">
                  <div className="relative aspect-square rounded-lg overflow-hidden border">
                    {isImage ? (
                      <img
                        src={objectUrl}
                        alt={file.name}
                        className="w-full h-full object-cover"
                      />
                    ) : (
                      <div className="w-full h-full flex items-center justify-center bg-muted">
                        <File className="h-12 w-12 text-muted-foreground" />
                      </div>
                    )}
 
                    <Button
                      variant="destructive"
                      size="icon"
                      className="absolute top-2 right-2 h-6 w-6"
                      onClick={() => removeFile(id)}
                      disabled={isDeleting || uploading}
                    >
                      {isDeleting ? (
                        <Loader2 className="h-3 w-3 animate-spin" />
                      ) : (
                        <Trash2 className="h-3 w-3" />
                      )}
                    </Button>
 
                    {uploading && !isDeleting && (
                      <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
                        <div className="text-white font-medium text-lg">
                          {progress}%
                        </div>
                      </div>
                    )}
 
                    {error && (
                      <div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
                        <div className="text-white font-medium">Error</div>
                      </div>
                    )}
                  </div>
                  <p className="text-xs text-muted-foreground truncate px-1">
                    {file.name}
                  </p>
                </div>
              );
            }
          )}
        </div>
      )}
    </div>
  );
}
 
// Cleanup hook
// useEffect(() => {
//   return () => {
//     files.forEach((file) => {
//       if (file.objectUrl) {
//         URL.revokeObjectURL(file.objectUrl);
//       }
//     });
//   };
// }, [files]);
 

2. AWS S3 Implementation

Setup Steps

1. Create AWS S3 Bucket

  1. Log into your AWS Console
  2. Navigate to S3 service
  3. Click "Create bucket"
  4. Choose a unique bucket name (e.g., my-app-uploads)
  5. Select your preferred region
  6. Block Public Access: Uncheck "Block all public access" if you want public file access
  7. Create the bucket

2. Configure Bucket Policy (Optional - for public access)

Go to your bucket → Permissions → Bucket Policy and add:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    }
  ]
}

Configure S3 CORS Policy

  1. Go to your S3 bucket in the AWS Console
  • Navigate to your bucket (jb-file-uploads in your case)
  • Click on the "Permissions" tab
  1. Scroll down to "Cross-origin resource sharing (CORS)"
  • Click "Edit"
  1. Add this CORS configuration:
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
    "AllowedOrigins": [
      "http://localhost:3000",
      "http://localhost:3001",
      "https://yourdomain.com"
    ],
    "ExposeHeaders": ["ETag", "x-amz-meta-custom-header"],
    "MaxAgeSeconds": 3000
  }
]
  1. Save the changes

For Production

  • When you deploy your app, make sure to update the AllowedOrigins to include your production domain:
"AllowedOrigins": [
    "http://localhost:3000",
    "https://your-production-domain.com"
]

3. Create IAM User

  1. Go to IAM → Users → Create User
  2. Choose a username (e.g., s3-upload-user)
  3. Attach policy: AmazonS3FullAccess (or create a custom policy for specific bucket access)
  4. Create access keys for the user
  5. Save the Access Key ID and Secret Access Key

Environment Variables

Create a .env.local file:

# AWS S3 Configuration
AWS_S3_REGION=us-east-1
AWS_S3_BUCKET_NAME=your_bucket_name
AWS_S3_ACCESS_KEY_ID=your_access_key_id
AWS_S3_SECRET_ACCESS_KEY=your_secret_access_key

API Routes

Install Dependencies

pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

S3 Client Setup

// lib/s3Client.ts
import "server-only";
import { S3Client } from "@aws-sdk/client-s3";
 
export const s3Client = new S3Client({
  region: process.env.AWS_S3_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_S3_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_S3_SECRET_ACCESS_KEY!,
  },
});

Upload Route

// app/api/s3/upload/route.ts
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client } from "@/lib/s3Client";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
 
const uploadRequestSchema = z.object({
  filename: z.string(),
  contentType: z.string(),
  size: z.number(),
});
 
function constructAwsS3Url(
  key: string,
  bucketName: string,
  region: string,
  customDomain?: string
): string {
  if (customDomain) {
    return `${customDomain}/${encodeURIComponent(key)}`;
  }
  return `https://${bucketName}.s3.${region}.amazonaws.com/${encodeURIComponent(
    key
  )}`;
}
export async function POST(request: Request) {
  try {
    const body = await request.json();
    const validation = uploadRequestSchema.safeParse(body);
 
    if (!validation.success) {
      return NextResponse.json(
        { error: "Invalid request body" },
        { status: 400 }
      );
    }
 
    const { filename, contentType, size } = validation.data;
    const uniqueKey = `${uuidv4()}-${filename}`;
 
    const command = new PutObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: uniqueKey,
      ContentType: contentType,
      ContentLength: size,
    });
 
    const presignedUrl = await getSignedUrl(s3Client, command, {
      expiresIn: 3600, // URL expires in 1 hour
    });
    const key = uniqueKey;
    const bucketName = process.env.AWS_S3_BUCKET_NAME!;
    const region = process.env.AWS_S3_REGION!;
    const publicUrl = constructAwsS3Url(key, bucketName, region);
    return NextResponse.json({
      presignedUrl,
      key: uniqueKey,
      publicUrl,
    });
  } catch (error) {
    console.error("Error generating presigned URL:", error);
    return NextResponse.json(
      { error: "Failed to generate upload URL" },
      { status: 500 }
    );
  }
}

Delete Route

// app/api/s3/delete/route.ts
import { NextResponse } from "next/server";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { s3Client } from "@/lib/s3Client";
 
export async function DELETE(request: Request) {
  try {
    const body = await request.json();
    const { key } = body;
 
    if (!key) {
      return NextResponse.json(
        { error: "File key is required" },
        { status: 400 }
      );
    }
 
    const command = new DeleteObjectCommand({
      Bucket: process.env.AWS_S3_BUCKET_NAME!,
      Key: key,
    });
 
    await s3Client.send(command);
 
    return NextResponse.json(
      { message: "File deleted successfully" },
      { status: 200 }
    );
  } catch (error) {
    console.error("Error deleting file:", error);
    return NextResponse.json(
      { error: "Failed to delete file" },
      { status: 500 }
    );
  }
}

Usage Example

// app/s3/page.tsx
"use client";
 
import { Dropzone, FileWithMetadata } from "@/components/ui/dropzone";
import { useState } from "react";
 
export default function HomePage() {
  const [files, setFiles] = useState<FileWithMetadata[]>();
  console.log(files);
  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">AWS S3 File Upload</h1>
      <Dropzone
        provider="aws-s3"
        variant="default"
        maxFiles={10}
        maxSize={1024 * 1024 * 50} // 50MB
        onFilesChange={(files) => setFiles(files)}
      />
    </div>
  );
}
 

Pros and Cons

Pros:

  • Industry standard, highly reliable
  • Extensive documentation and community support
  • Advanced features (lifecycle policies, versioning, etc.)
  • Global CDN integration with CloudFront
  • Fine-grained access control with IAM

Cons:

  • More complex setup and pricing structure
  • Can be expensive for high-bandwidth usage
  • Learning curve for AWS-specific concepts
  • Vendor lock-in concerns

3. Cloudflare R2 Implementation

Setup Steps

1. Create Cloudflare Account

  1. Sign up for Cloudflare account
  2. Add a payment method (required for R2, but has generous free tier)
  3. Navigate to R2 Object Storage in the dashboard

2. Create R2 Bucket

  1. Click "Create bucket"
  2. Choose a bucket name (e.g., my-app-uploads)
  3. Select location (automatic is fine)
  4. Create the bucket

3. Configure CORS (if needed)

In your bucket settings, add CORS rules:

[
  {
    "AllowedOrigins": ["https://yourdomain.com", "http://localhost:3000"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedHeaders": ["*"]
  }
]

4. Create API Token (See near the button of Create Bucket -> API dropdown)

  1. Go to R2 → Manage R2 API tokens
  2. Click "Create API token"
  3. Give it a name and admin permissions
  4. Copy the Access Key ID, Secret Access Key, and Endpoint URL

Environment Variables

# Cloudflare R2 Configuration
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key
CLOUDFLARE_R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
CLOUDFLARE_R2_BUCKET_NAME=your_bucket_name

API Routes

R2 Client Setup

// lib/r2Client.ts
import "server-only";
import { S3Client } from "@aws-sdk/client-s3";
 
export const r2Client = new S3Client({
  region: "auto",
  endpoint: process.env.CLOUDFLARE_R2_ENDPOINT!,
  credentials: {
    accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY!,
  },
  forcePathStyle: false,
});

Upload Route

// app/api/r2/upload/route.ts
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { r2Client } from "@/lib/r2Client";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
 
const uploadRequestSchema = z.object({
  filename: z.string(),
  contentType: z.string(),
  size: z.number(),
});
function constructCloudflareR2Url(
  key: string,
  bucketName: string,
  customDomain?: string
): string {
  if (customDomain) {
    return `${customDomain}/${encodeURIComponent(key)}`;
  }
  // R2 public URL format (requires public access setup)
  return `https://pub-${bucketName}.r2.dev/${encodeURIComponent(key)}`;
}
export async function POST(request: Request) {
  try {
    const body = await request.json();
    const validation = uploadRequestSchema.safeParse(body);
 
    if (!validation.success) {
      return NextResponse.json(
        { error: "Invalid request body" },
        { status: 400 }
      );
    }
 
    const { filename, contentType, size } = validation.data;
    const uniqueKey = `${uuidv4()}-${filename}`;
 
    const command = new PutObjectCommand({
      Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
      Key: uniqueKey,
      ContentType: contentType,
      ContentLength: size,
    });
 
    const presignedUrl = await getSignedUrl(r2Client, command, {
      expiresIn: 3600, // URL expires in 1 hour
    });
    const key = uniqueKey;
    const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME!;
 
    const publicUrl = constructCloudflareR2Url(key, bucketName);
 
    return NextResponse.json({
      presignedUrl,
      key: uniqueKey,
      publicUrl,
    });
  } catch (error) {
    console.error("Error generating presigned URL:", error);
    return NextResponse.json(
      { error: "Failed to generate upload URL" },
      { status: 500 }
    );
  }
}

Delete Route

// app/api/r2/delete/route.ts
import { NextResponse } from "next/server";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { r2Client } from "@/lib/r2Client";
 
export async function DELETE(request: Request) {
  try {
    const body = await request.json();
    const { key } = body;
 
    if (!key) {
      return NextResponse.json(
        { error: "File key is required" },
        { status: 400 }
      );
    }
 
    const command = new DeleteObjectCommand({
      Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
      Key: key,
    });
 
    await r2Client.send(command);
 
    return NextResponse.json(
      { message: "File deleted successfully" },
      { status: 200 }
    );
  } catch (error) {
    console.error("Error deleting file:", error);
    return NextResponse.json(
      { error: "Failed to delete file" },
      { status: 500 }
    );
  }
}

Usage Example

// app/r2-demo/page.tsx
"use client";
import { Dropzone, FileWithMetadata } from "@/components/ui/dropzone";
import { useState } from "react";
 
export default function HomePage() {
  const [files, setFiles] = useState<FileWithMetadata[]>();
  console.log(files);
  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">Cloudflare R2 File Upload</h1>
      <Dropzone
        provider="cloudflare-r2"
        variant="compact"
        maxFiles={10}
        maxSize={1024 * 1024 * 50} // 50MB
        onFilesChange={(files) => setFiles(files)}
      />
    </div>
  );
}
 

Pros and Cons

Pros:

  • Very competitive pricing, especially for egress
  • S3-compatible API (easy migration)
  • Fast global network
  • No egress fees
  • Simple pricing model

Cons:

  • Newer service (less mature than S3)
  • Smaller ecosystem and community
  • Limited advanced features compared to S3
  • Requires Cloudflare account with payment method

4. Complete Demo Page

Here's a comprehensive demo page that showcases both providers with a file management table:

// app/demo/page.tsx
"use client";
 
import { useState } from "react";
import { Dropzone, FileWithMetadata } from "@/components/ui/dropzone";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trash2, Download, Eye } from "lucide-react";
 
export default function DemoPage() {
  const [s3Files, setS3Files] = useState<FileWithMetadata[]>([]);
  const [r2Files, setR2Files] = useState<FileWithMetadata[]>([]);
  const [activeProvider, setActiveProvider] = useState<"aws-s3" | "cloudflare-r2">("aws-s3");
 
  const formatFileSize = (bytes: number) => {
    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];
  };
 
  const getFileIcon = (type: string) => {
    if (type.startsWith("image/")) return "🖼️";
    if (type.includes("pdf")) return "📄";
    if (type.includes("word")) return "📝";
    if (type.includes("zip")) return "📦";
    return "📁";
  };
 
  const currentFiles = activeProvider === "aws-s3" ? s3Files : r2Files;
 
  return (
    <div className="max-w-6xl mx-auto p-6">
      <div className="mb-8">
        <h1 className="text-3xl font-bold mb-2">File Upload Demo</h1>
        <p className="text-muted-foreground">
          Test file uploads with AWS S3 and Cloudflare R2 using our reusable dropzone component.
        </p>
      </div>
 
      <Tabs value={activeProvider} onValueChange={(v) => setActiveProvider(v as any)} className="mb-8">
        <TabsList className="grid w-full grid-cols-2">
          <TabsTrigger value="aws-s3">AWS S3</TabsTrigger>
          <TabsTrigger value="cloudflare-r2">Cloudflare R2</TabsTrigger>
        </TabsList>
 
        <TabsContent value="aws-s3" className="space-y-6">
          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2">
                <span>AWS S3 Upload</span>
                <Badge variant="secondary">Reliable & Mature</Badge>
              </CardTitle>
            </CardHeader>
            <CardContent>
              <Dropzone
                provider="aws-s3"
                variant="default"
                maxFiles={10}
                maxSize={1024 * 1024 * 50} // 50MB
                onFilesChange={setS3Files}
              />
            </CardContent>
          </Card>
        </TabsContent>
 
        <TabsContent value="cloudflare-r2" className="space-y-6">
          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2">
                <span>Cloudflare R2 Upload</span>
                <Badge variant="secondary">Cost Effective</Badge>
              </CardTitle>
            </CardHeader>
            <CardContent>
              <Dropzone
                provider="cloudflare-r2"
                variant="default"
                maxFiles={10}
                maxSize={1024 * 1024 * 50} // 50MB
                onFilesChange={setR2Files}
              />
            </CardContent>
          </Card>
        </TabsContent>
      </Tabs>
 
      {/* File Management Table */}
      <Card>
        <CardHeader>
          <CardTitle className="flex items-center justify-between">
            <span>Uploaded Files ({currentFiles.length})</span>
            <Badge variant="outline">
              {activeProvider === "aws-s3" ? "AWS S3" : "Cloudflare R2"}
            </Badge>
          </CardTitle>
        </CardHeader>
        <CardContent>
          {currentFiles.length === 0 ? (
            <div className="text-center py-8 text-muted-foreground">
              No files uploaded yet. Use the dropzone above to upload files.
            </div>
          ) : (
            <Table>
              <TableHeader>
                <TableRow>
                  <TableHead className="w-[50px]">Type</TableHead>
                  <TableHead>Filename</TableHead>
                  <TableHead>Size</TableHead>
                  <TableHead>Status</TableHead>
                  <TableHead>Progress</TableHead>
                  <TableHead className="text-right">Actions</TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {currentFiles.map((file) => (
                  <TableRow key={file.id}>
                    <TableCell>
                      <span className="text-2xl">
                        {getFileIcon(file.file.type)}
                      </span>
                    </TableCell>
                    <TableCell className="font-medium max-w-[200px] truncate">
                      {file.file.name}
                    </TableCell>
                    <TableCell>{formatFileSize(file.file.size)}</TableCell>
                    <TableCell>
                      {file.error ? (
                        <Badge variant="destructive">Error</Badge>
                      ) : file.uploading ? (
                        <Badge variant="secondary">Uploading</Badge>
                      ) : file.isDeleting ? (
                        <Badge variant="secondary">Deleting</Badge>
                      ) : (
                        <Badge variant="default">Completed</Badge>
                      )}
                    </TableCell>
                    <TableCell>
                      {file.uploading ? (
                        <div className="flex items-center gap-2">
                          <div className="w-20 bg-muted rounded-full h-2">
                            <div
                              className="bg-primary h-2 rounded-full transition-all duration-300"
                              style={{ width: `${file.progress}%` }}
                            />
                          </div>
                          <span className="text-sm text-muted-foreground">
                            {file.progress}%
                          </span>
                        </div>
                      ) : file.error ? (
                        <span className="text-sm text-destructive">Failed</span>
                      ) : (
                        <span className="text-sm text-muted-foreground">Complete</span>
                      )}
                    </TableCell>
                    <TableCell className="text-right">
                      <div className="flex items-center gap-1 justify-end">
                        {file.objectUrl && file.file.type.startsWith("image/") && (
                          <Button
                            variant="ghost"
                            size="sm"
                            onClick={() => window.open(file.objectUrl, '_blank')}
                            disabled={file.uploading || file.isDeleting}
                          >
                            <Eye className="h-4 w-4" />
                          </Button>
                        )}
                        <Button
                          variant="ghost"
                          size="sm"
                          onClick={() => {
                            const link = document.createElement('a');
                            link.href = file.objectUrl!;
                            link.download = file.file.name;
                            link.click();
                          }}
                          disabled={file.uploading || file.isDeleting}
                        >
                          <Download className="h-4 w-4" />
                        </Button>
                        <Button
                          variant="ghost"
                          size="sm"
                          onClick={() => {
                            // This would call the removeFile function from the Dropzone component
                            // For demo purposes, we'll show how it would work
                            console.log('Delete file:', file.id);
                          }}
                          disabled={file.uploading || file.isDeleting}
                          className="text-destructive hover:text-destructive"
                        >
                          <Trash2 className="h-4 w-4" />
                        </Button>
                      </div>
                    </TableCell>
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          )}
        </CardContent>
      </Card>
 
      {/* Stats Card */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold">{currentFiles.length}</div>
            <p className="text-xs text-muted-foreground">Total Files</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold">
              {formatFileSize(
                currentFiles.reduce((total, file) => total + file.file.size, 0)
              )}
            </div>
            <p className="text-xs text-muted-foreground">Total Size</p>
          </CardContent>
        </Card>
        <Card>
          <CardContent className="pt-6">
            <div className="text-2xl font-bold">
              {currentFiles.filter(f => !f.uploading && !f.error).length}
            </div>
            <p className="text-xs text-muted-foreground">Completed</p>
          </CardContent>
        </Card>
      </div>
    </div>
  );
}

Additional Components

File Management Hook

For better state management, you can create a custom hook:

// hooks/useFileUpload.ts
import { useState, useCallback } from "react";
import { FileWithMetadata } from "@/components/ui/dropzone";
 
export function useFileUpload() {
  const [files, setFiles] = useState<FileWithMetadata[]>([]);
 
  const addFiles = useCallback((newFiles: FileWithMetadata[]) => {
    setFiles((prev) => [...prev, ...newFiles]);
  }, []);
 
  const updateFile = useCallback(
    (fileId: string, updates: Partial<FileWithMetadata>) => {
      setFiles((prev) =>
        prev.map((file) =>
          file.id === fileId ? { ...file, ...updates } : file
        )
      );
    },
    []
  );
 
  const removeFile = useCallback((fileId: string) => {
    setFiles((prev) => {
      const fileToRemove = prev.find((f) => f.id === fileId);
      if (fileToRemove?.objectUrl) {
        URL.revokeObjectURL(fileToRemove.objectUrl);
      }
      return prev.filter((f) => f.id !== fileId);
    });
  }, []);
 
  const clearFiles = useCallback(() => {
    files.forEach((file) => {
      if (file.objectUrl) {
        URL.revokeObjectURL(file.objectUrl);
      }
    });
    setFiles([]);
  }, [files]);
 
  const getFilesByStatus = useCallback(
    (status: "uploading" | "completed" | "error") => {
      switch (status) {
        case "uploading":
          return files.filter((f) => f.uploading);
        case "completed":
          return files.filter((f) => !f.uploading && !f.error);
        case "error":
          return files.filter((f) => f.error);
        default:
          return files;
      }
    },
    [files]
  );
 
  return {
    files,
    addFiles,
    updateFile,
    removeFile,
    clearFiles,
    getFilesByStatus,
    stats: {
      total: files.length,
      totalSize: files.reduce((sum, file) => sum + file.file.size, 0),
      completed: files.filter((f) => !f.uploading && !f.error).length,
      uploading: files.filter((f) => f.uploading).length,
      errors: files.filter((f) => f.error).length,
    },
  };
}

Progress Component

A reusable progress component for file uploads:

// components/ui/progress.tsx
import { cn } from "@/lib/utils";
 
interface ProgressProps {
  value: number;
  className?: string;
  showPercentage?: boolean;
}
 
export function Progress({ value, className, showPercentage = true }: ProgressProps) {
  return (
    <div className={cn("flex items-center gap-2", className)}>
      <div className="flex-1 bg-muted rounded-full h-2">
        <div
          className="bg-primary h-2 rounded-full transition-all duration-300 ease-out"
          style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
        />
      </div>
      {showPercentage && (
        <span className="text-xs text-muted-foreground min-w-[3ch]">
          {Math.round(value)}%
        </span>
      )}
    </div>
  );
}

Best Practices

Security Considerations

  1. Validate file types server-side - Never trust client-side validation alone
  2. Implement rate limiting - Prevent abuse of upload endpoints
  3. Scan for malware - Consider integrating virus scanning for uploaded files
  4. Use presigned URLs - Avoid passing files through your server
  5. Set appropriate CORS policies - Restrict origins that can upload files

Performance Optimization

  1. Implement chunked uploads for large files
  2. Use progressive JPEG for images
  3. Implement client-side compression before upload
  4. Consider using CDN for file delivery
  5. Implement caching strategies for frequently accessed files

Error Handling

// utils/uploadErrorHandler.ts
export function handleUploadError(error: any): string {
  if (error.name === "NetworkError") {
    return "Network connection failed. Please check your internet connection.";
  }
 
  if (error.status === 413) {
    return "File is too large. Please reduce file size and try again.";
  }
 
  if (error.status === 415) {
    return "File type not supported. Please choose a different file.";
  }
 
  if (error.status >= 500) {
    return "Server error occurred. Please try again later.";
  }
 
  return "Upload failed. Please try again.";
}

Comparison Summary

FeatureAWS S3Cloudflare R2
PricingComplex, higher egress costsSimple, zero egress fees
Reliability99.999999999% durability99.999999999% durability
Global CDNCloudFront integrationBuilt-in global network
API CompatibilityNative S3 APIS3-compatible API
EcosystemExtensive AWS ecosystemGrowing ecosystem
Setup ComplexityModerate (IAM, policies)Simple
Free TierLimited10GB storage, 1M operations

Troubleshooting

Common Issues

  1. CORS Errors: Ensure proper CORS configuration on your bucket
  2. Presigned URL Expiry: Check if URLs are expiring too quickly
  3. File Size Limits: Verify both client and server size limits
  4. Network Timeouts: Implement proper retry mechanisms
  5. Memory Issues: Use streaming for large file uploads

Debug Mode

Enable debug logging in development:

// utils/debug.ts
export const debug = {
  log: (message: string, data?: any) => {
    if (process.env.NODE_ENV === "development") {
      console.log(`[Upload Debug]: ${message}`, data);
    }
  },
  error: (message: string, error?: any) => {
    if (process.env.NODE_ENV === "development") {
      console.error(`[Upload Error]: ${message}`, error);
    }
  },
};

This comprehensive guide provides everything you need to implement robust file uploads in Next.js with both AWS S3 and Cloudflare R2, featuring a reusable dropzone component that works with either provider.