Presigned URLs let users upload files directly to cloud storage (S3, R2, GCS) without routing data through your server. This is faster, cheaper, and more scalable.
Step 1: Install AWS SDK
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Step 2: Configure S3 Client
// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
export const BUCKET_NAME = process.env.S3_BUCKET_NAME!;
Step 3: Create the Presigned URL API Route
// app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET_NAME } from "@/lib/s3";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
];
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
export async function POST(req: NextRequest) {
const { filename, contentType, size } = await req.json();
// Validate file type
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json(
{ error: "File type not allowed" },
{ status: 400 }
);
}
// Validate file size
if (size > MAX_SIZE) {
return NextResponse.json(
{ error: "File too large. Max 10 MB." },
{ status: 400 }
);
}
// Generate a unique key
const ext = filename.split(".").pop();
const key = `uploads/${randomUUID()}.${ext}`;
const command = new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
ContentType: contentType,
ContentLength: size,
});
const presignedUrl = await getSignedUrl(s3, command, {
expiresIn: 60, // URL expires in 60 seconds
});
return NextResponse.json({
presignedUrl,
key,
url: `https://${BUCKET_NAME}.s3.amazonaws.com/${key}`,
});
}
Step 4: Build the Upload Component
"use client";
import { useState, useRef } from "react";
interface UploadResult {
url: string;
key: string;
}
export function FileUpload({
onUploadComplete,
}: {
onUploadComplete: (result: UploadResult) => void;
}) {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setUploading(true);
setProgress(0);
try {
// 1. Get presigned URL from our API
const res = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to get upload URL");
}
const { presignedUrl, key, url } = await res.json();
// 2. Upload directly to S3
await uploadWithProgress(presignedUrl, file, setProgress);
// 3. Notify parent
onUploadComplete({ url, key });
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="space-y-3">
<label className="block">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Upload File
</span>
<input
ref={inputRef}
type="file"
onChange={handleUpload}
accept="image/jpeg,image/png,image/webp,application/pdf"
disabled={uploading}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-600 hover:file:bg-blue-100 disabled:opacity-50"
/>
</label>
{uploading && (
<div className="space-y-1">
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-xs text-gray-500">{Math.round(progress)}%</p>
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}
function uploadWithProgress(
url: string,
file: File,
onProgress: (pct: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100);
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.send(file);
});
}
Step 5: Drag-and-Drop Zone
"use client";
import { useState, useCallback } from "react";
export function DropZone({ onFile }: { onFile: (file: File) => void }) {
const [dragging, setDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragging(true);
}, []);
const handleDragLeave = useCallback(() => {
setDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) onFile(file);
},
[onFile]
);
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex h-40 items-center justify-center rounded-xl border-2 border-dashed transition-colors ${
dragging
? "border-blue-500 bg-blue-50 dark:bg-blue-900/10"
: "border-gray-300 dark:border-gray-600"
}`}
>
<p className="text-sm text-gray-500">
{dragging ? "Drop file here" : "Drag and drop a file here, or click to browse"}
</p>
</div>
);
}
Step 6: Image Preview
function ImagePreview({ file }: { file: File }) {
const [preview, setPreview] = useState<string | null>(null);
useEffect(() => {
if (!file.type.startsWith("image/")) return;
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!preview) return null;
return (
<img
src={preview}
alt="Preview"
className="h-32 w-32 rounded-lg object-cover"
/>
);
}
Step 7: Multi-File Upload
async function uploadMultiple(files: File[]) {
const results = await Promise.allSettled(
files.map((file) => uploadSingleFile(file))
);
const successes = results
.filter((r) => r.status === "fulfilled")
.map((r) => r.value);
const failures = results
.filter((r) => r.status === "rejected")
.map((r) => r.reason);
return { successes, failures };
}
Step 8: Using with Cloudflare R2
Cloudflare R2 has an S3-compatible API. Just change the client config:
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
Everything else stays the same.
Need a Custom Upload System?
We build web applications with secure file upload systems, media management, and cloud storage integration. Get in touch to discuss your project.