Store and serve files securely using AWS S3 or an S3-compatible service like Cloudflare R2.
Step 1: Install Dependencies
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Step 2: S3 Client Configuration
// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
export const s3Client = new S3Client({
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT, // For R2 or MinIO
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Required for some S3-compatible services
});
export const BUCKET_NAME = process.env.S3_BUCKET_NAME!;
Step 3: Upload Presigned URL API
// app/api/files/presigned-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 { s3Client, BUCKET_NAME } from "@/lib/s3";
import { randomUUID } from "crypto";
import { z } from "zod";
const schema = z.object({
filename: z.string().min(1).max(255),
contentType: z.string().min(1),
size: z.number().max(10 * 1024 * 1024), // 10MB max
});
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
"text/csv",
];
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { filename, contentType, size } = parsed.data;
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "File type not allowed" }, { 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(s3Client, command, {
expiresIn: 300, // 5 minutes
});
return NextResponse.json({ presignedUrl, key });
}
Step 4: Download Presigned URL API
// app/api/files/presigned-download/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3Client, BUCKET_NAME } from "@/lib/s3";
export async function GET(request: NextRequest) {
const key = new URL(request.url).searchParams.get("key");
if (!key) {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
// Validate key format to prevent path traversal
if (key.includes("..") || !key.startsWith("uploads/")) {
return NextResponse.json({ error: "Invalid key" }, { status: 400 });
}
const command = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
const presignedUrl = await getSignedUrl(s3Client, command, {
expiresIn: 3600, // 1 hour
});
return NextResponse.json({ url: presignedUrl });
}
Step 5: Delete File API
// app/api/files/[key]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "@/lib/s3";
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ key: string }> }
) {
const { key } = await params;
const decodedKey = decodeURIComponent(key);
if (decodedKey.includes("..") || !decodedKey.startsWith("uploads/")) {
return NextResponse.json({ error: "Invalid key" }, { status: 400 });
}
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: decodedKey,
})
);
return NextResponse.json({ success: true });
}
Step 6: List Files API
// app/api/files/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ListObjectsV2Command } from "@aws-sdk/client-s3";
import { s3Client, BUCKET_NAME } from "@/lib/s3";
export async function GET(request: NextRequest) {
const prefix = new URL(request.url).searchParams.get("prefix") ?? "uploads/";
const continuationToken =
new URL(request.url).searchParams.get("cursor") ?? undefined;
const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
Prefix: prefix,
MaxKeys: 50,
ContinuationToken: continuationToken,
});
const response = await s3Client.send(command);
const files = (response.Contents ?? []).map((obj) => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified?.toISOString(),
}));
return NextResponse.json({
files,
nextCursor: response.NextContinuationToken ?? null,
hasMore: response.IsTruncated ?? false,
});
}
Step 7: Upload Component
// components/FileUpload.tsx
"use client";
import { useState, useRef } from "react";
interface UploadedFile {
key: string;
name: string;
size: number;
url?: string;
}
export function FileUpload({ onUpload }: { onUpload: (file: UploadedFile) => 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;
setUploading(true);
setError(null);
setProgress(0);
try {
// Get presigned URL
const res = await fetch("/api/files/presigned-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 } = await res.json();
// Upload directly to S3
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", presignedUrl);
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error("Upload failed"));
}
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(file);
});
onUpload({ key, name: file.name, size: file.size });
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
setProgress(0);
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="space-y-2">
<label className="block cursor-pointer rounded-lg border-2 border-dashed border-gray-300 p-6 text-center transition hover:border-gray-400">
<input
ref={inputRef}
type="file"
onChange={handleUpload}
disabled={uploading}
className="hidden"
accept="image/*,.pdf,.csv"
/>
{uploading ? (
<div>
<p className="text-sm text-gray-600">Uploading... {progress}%</p>
<div className="mx-auto mt-2 h-2 w-48 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-blue-600 transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : (
<div>
<p className="text-sm font-medium text-gray-600">
Click or drag to upload a file
</p>
<p className="mt-1 text-xs text-gray-400">
Images, PDFs, or CSV up to 10MB
</p>
</div>
)}
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
);
}
Step 8: File List Component
// components/FileList.tsx
"use client";
import { useState, useEffect } from "react";
interface FileItem {
key: string;
size: number;
lastModified: string;
}
function formatBytes(bytes: number) {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export function FileList() {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/files")
.then((res) => res.json())
.then((data) => setFiles(data.files))
.finally(() => setLoading(false));
}, []);
async function handleDownload(key: string) {
const res = await fetch(
`/api/files/presigned-download?key=${encodeURIComponent(key)}`
);
const { url } = await res.json();
window.open(url, "_blank");
}
async function handleDelete(key: string) {
if (!confirm("Delete this file?")) return;
await fetch(`/api/files/${encodeURIComponent(key)}`, {
method: "DELETE",
});
setFiles((prev) => prev.filter((f) => f.key !== key));
}
if (loading) return <p className="text-sm text-gray-500">Loading files...</p>;
if (files.length === 0) return <p className="text-sm text-gray-500">No files uploaded.</p>;
return (
<div className="divide-y rounded-lg border">
{files.map((file) => (
<div key={file.key} className="flex items-center justify-between px-4 py-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{file.key.split("/").pop()}</p>
<p className="text-xs text-gray-500">
{formatBytes(file.size)} — {new Date(file.lastModified).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => handleDownload(file.key)}
className="text-sm text-blue-600 hover:underline"
>
Download
</button>
<button
onClick={() => handleDelete(file.key)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
))}
</div>
);
}
Need File Management for Your App?
We build secure file storage, media libraries, and document management systems. Contact us to get started.