File Uploads
S3-compatible file uploads with presigned URLs.
Overview
Adds S3-compatible file uploads with presigned URLs and a drag-and-drop upload component.
Prerequisites
None.
Installation
npx ds-start init my-app --file-uploads
# or add to existing project
npx ds-start add file-uploadsWhat It Adds
lib/storage.ts— S3 client with presigned URL helpersapp/api/uploads/presign/route.ts— Presign API endpointcomponents/file-upload.tsx— Drag-and-drop upload component
Compatible Providers
- AWS S3
- Cloudflare R2
- MinIO
- Backblaze B2
Environment Variables
| Variable | Description |
|---|---|
S3_BUCKET | Storage bucket name |
S3_REGION | Bucket region |
S3_ENDPOINT | S3-compatible endpoint URL |
S3_ACCESS_KEY_ID | Access key ID |
S3_SECRET_ACCESS_KEY | Secret access key |
Usage
Storage helper
The storage module provides presigned URL generation for both uploads and downloads:
import {
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
const s3 = new S3Client({
region: process.env.S3_REGION ?? "us-east-1",
endpoint: process.env.S3_ENDPOINT || undefined,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? "",
},
forcePathStyle: true,
})
const bucket = process.env.S3_BUCKET ?? ""
export async function createPresignedUpload(options: {
key: string
contentType: string
maxSize?: number
expiresIn?: number
}): Promise<string> {
const { key, contentType, maxSize, expiresIn = 600 } = options
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
...(maxSize ? { ContentLength: maxSize } : {}),
})
return getSignedUrl(s3, command, { expiresIn })
}
export async function createPresignedDownload(options: {
key: string
expiresIn?: number
}): Promise<string> {
const { key, expiresIn = 3600 } = options
const command = new GetObjectCommand({ Bucket: bucket, Key: key })
return getSignedUrl(s3, command, { expiresIn })
}
export function generateUploadKey(filename: string): string {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, "0")
const id = crypto.randomUUID().slice(0, 8)
const sanitized = filename.replace(/[^a-zA-Z0-9._-]/g, "_")
return `uploads/${year}/${month}/${id}-${sanitized}`
}Presign API route
The API validates file type and size, then returns a presigned PUT URL and storage key:
import { createPresignedUpload, generateUploadKey } from "@/lib/storage"
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ALLOWED_CONTENT_TYPES = new Set([
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
"application/pdf", "text/plain", "text/csv",
])
export async function POST(request: Request) {
const body = await request.json()
if (!body.filename || !body.contentType || typeof body.size !== "number") {
return Response.json(
{ error: "Missing required fields: filename, contentType, size" },
{ status: 400 },
)
}
if (body.size > MAX_FILE_SIZE) {
return Response.json(
{ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` },
{ status: 400 },
)
}
if (!ALLOWED_CONTENT_TYPES.has(body.contentType)) {
return Response.json(
{ error: `Content type not allowed: ${body.contentType}` },
{ status: 400 },
)
}
const key = generateUploadKey(body.filename)
const url = await createPresignedUpload({
key,
contentType: body.contentType,
maxSize: body.size,
})
return Response.json({ url, key })
}Client-side upload flow
The upload component handles presigning and direct-to-S3 upload with progress:
// 1. Request a presigned URL
const response = await fetch("/api/uploads/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
})
const { url, key } = await response.json()
// 2. Upload directly to S3 with progress tracking
const xhr = new XMLHttpRequest()
xhr.open("PUT", url)
xhr.setRequestHeader("Content-Type", file.type)
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100)
console.log(`Upload progress: ${percent}%`)
}
})
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
console.log(`Uploaded to key: ${key}`)
}
})
xhr.send(file)Using the FileUpload component
A ready-made drag-and-drop component is included:
import { FileUpload } from "@/components/file-upload"
export function MyUploadPage() {
return (
<FileUpload
accept="image/*"
maxSizeMB={10}
multiple={false}
onUploadComplete={(url) => console.log("Uploaded:", url)}
onError={(error) => console.error("Upload failed:", error)}
/>
)
}