File Uploads
intermediate*Serverless file uploads with presigned URLs. Supports AWS S3 and Vercel Blob with file validation.
$ bunx sinew add infrastructure/file-uploads1The Problem
Handling file uploads in serverless requires:
- Direct uploads to storage (not through your server)
- File type and size validation
- Support for multiple providers
- Presigned URLs for secure uploads
2The Solution
Use Vercel Blob for simple deployments or AWS S3 with presigned URLs for more control. Includes validation and a React hook for uploads.
3Files
lib/uploads/config.ts
export const uploadConfig = {
maxFileSize: 10 * 1024 * 1024, // 10MB
allowedMimeTypes: ["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"],
blockedExtensions: [".exe", ".bat", ".sh", ".php"],
};
export function validateFile(
filename: string,
contentType: string,
size: number
): { valid: boolean; error?: string } {
if (size > uploadConfig.maxFileSize) {
return { valid: false, error: "File too large" };
}
if (!uploadConfig.allowedMimeTypes.includes(contentType)) {
return { valid: false, error: `File type ${contentType} not allowed` };
}
const ext = filename.toLowerCase().slice(filename.lastIndexOf("."));
if (uploadConfig.blockedExtensions.includes(ext)) {
return { valid: false, error: `Extension ${ext} not allowed` };
}
return { valid: true };
}lib/uploads/s3.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { validateFile } from "./config";
const s3 = new S3Client({
region: process.env.AWS_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const BUCKET = process.env.AWS_S3_BUCKET!;
const REGION = process.env.AWS_REGION ?? "us-east-1";
export async function getPresignedUploadUrl(filename: string, contentType: string, size: number) {
const validation = validateFile(filename, contentType, size);
if (!validation.valid) throw new Error(validation.error);
const key = `uploads/${Date.now()}-${filename}`;
// ContentLength is advisory: a client can lie about `size` in the request body
// since the URL is signed from client-supplied values. For hard enforcement use
// a presigned POST with a content-length-range condition or a bucket policy.
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
ContentLength: size,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 3600 });
return {
uploadUrl,
// Region-correct virtual-hosted URL. Only reachable if the object is public;
// the SSL cert does not cover bucket names with dots — use a presigned GET there.
publicUrl: `https://${BUCKET}.s3.${REGION}.amazonaws.com/${key}`,
key,
};
}app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getPresignedUploadUrl } from "@/lib/uploads/s3";
export async function POST(req: NextRequest) {
// TODO: authenticate the request before issuing an upload URL. This grants
// write access to your storage, so reject anonymous callers here.
const { filename, contentType, size } = await req.json();
if (!filename || !contentType || !size) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const result = await getPresignedUploadUrl(filename, contentType, size);
return NextResponse.json(result);
}hooks/use-file-upload.ts
"use client";
import { useState, useCallback } from "react";
export function useFileUpload() {
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = useCallback(async (file: File) => {
setIsUploading(true);
setError(null);
try {
// Get presigned URL
const response = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
const { uploadUrl, publicUrl, key } = await response.json();
// Upload directly to S3
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
return { url: publicUrl, key };
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
return null;
} finally {
setIsUploading(false);
}
}, []);
return { uploadFile, isUploading, error };
}4Dependencies
$ bun add @vercel/blob @aws-sdk/client-s35Configuration
Environment Variables
| Variable | Description | Required |
| ----------------------- | --------------------- | -------------- |
| UPLOAD_PROVIDER | "vercel-blob" or "s3" | No |
| AWS_REGION | AWS region | Yes (for S3) |
| AWS_ACCESS_KEY_ID | AWS access key | Yes (for S3) |
| AWS_SECRET_ACCESS_KEY | AWS secret | Yes (for S3) |
| AWS_S3_BUCKET | S3 bucket name | Yes (for S3) |
| BLOB_READ_WRITE_TOKEN | Vercel Blob token | Yes (for Blob) |
6Usage
"use client";
import { useFileUpload } from "@/hooks/use-file-upload";
export function UploadForm() {
const { uploadFile, isUploading, error } = useFileUpload();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const result = await uploadFile(file);
if (result) console.log("Uploaded:", result.url);
}
};
return (
<div>
<input type="file" onChange={handleUpload} disabled={isUploading} />
{error && <p className="text-red-500">{error}</p>}
</div>
);
}7Alternatives
- Vercel Blob - Zero-config on Vercel
- Uploadthing - Type-safe file routes with React components
- Cloudflare R2 - S3-compatible with no egress fees