File Upload to S3/R2
Presigned URLs, multipart upload, progress tracking, and file validation
What You’ll Build
After following this guide, you will have a working implementation of file upload to s3/r2 in your project. Upload files securely using presigned URLs — files go directly from the browser to S3/R2, bypassing your server. This saves bandwidth, reduces server load, and handles large files. Covers presigned URL generation, client-side upload with progress, and file type/size validation.
Use Cases & Problems Solved
- Build reliable server endpoints that clients can consume consistently
- Handle common backend patterns like routing, middleware, and error handling
- Provide a clean interface between your frontend and data layer
Prerequisites
- AWS S3 bucket or Cloudflare R2 bucket
- AWS SDK v3 or S3-compatible client
Step-by-Step Implementation
Install AWS SDK
The following snippet shows how to install aws sdk. Copy this into your project and adjust the values for your environment.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Generate presigned upload URL (server)
The following snippet shows how to generate presigned upload url (server). Copy this into your project and adjust the values for your environment.
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const s3 = new S3Client({
region: 'auto',
endpoint: process.env.S3_ENDPOINT, // For R2: https://<account>.r2.cloudflarestorage.com
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY,
secretAccessKey: process.env.S3_SECRET_KEY,
},
});
app.post('/api/upload-url', authenticate, async (req, res) => {
const { filename, contentType } = req.body;
const key = \`uploads/\${req.user.id}/\${Date.now()}-\${filename}\`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: contentType,
}), { expiresIn: 600 }); // 10 min expiry
res.json({ uploadUrl: url, key });
});
Upload from browser with progress
The following snippet shows how to upload from browser with progress. Copy this into your project and adjust the values for your environment.
async function uploadFile(file) {
// 1. Get presigned URL from your API
const { uploadUrl, key } = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then(r => r.json());
// 2. Upload directly to S3/R2
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
const percent = Math.round((e.loaded / e.total) * 100);
console.log(\`Upload: \${percent}%\`);
};
return new Promise((resolve, reject) => {
xhr.onload = () => resolve(key);
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
});
}
⚠️ Don’t Do This
❌ Uploading files through your server (wastes bandwidth)
// File goes: Browser → Your Server → S3
// Your server processes every byte — 100MB file = 100MB bandwidth
app.post('/upload', upload.single('file'), async (req, res) => {
await s3.upload({ Body: req.file.buffer, ... });
});
✅ Use presigned URLs so files go directly to S3/R2
// File goes: Browser → S3 directly
// Your server only generates the URL (tiny request)
const url = await getSignedUrl(s3, new PutObjectCommand({...}));
Testing
Add these tests to verify your API endpoints work correctly:
// __tests__/api.test.ts
import { describe, it, expect } from 'vitest';
describe('File Upload to S3/R2', () => {
it('should return 200 for valid requests', async () => {
const res = await fetch('/api/endpoint', { method: 'GET' });
expect(res.status).toBe(200);
const data = await res.json();
expect(data).toBeDefined();
});
it('should return 400 for invalid input', async () => {
const res = await fetch('/api/endpoint', {
method: 'POST',
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it('should handle errors gracefully', async () => {
const res = await fetch('/api/endpoint/nonexistent');
expect(res.status).toBe(404);
});
});
Verification
# Generate upload URL:
curl -X POST http://localhost:3000/api/upload-url \
-H 'Content-Type: application/json' \
-d '{"filename":"test.png","contentType":"image/png"}'
# Upload a file to the returned URL:
curl -X PUT '<presigned-url>' -H 'Content-Type: image/png' --data-binary @test.png Related Specs
Secure Webhook Handler
Signature verification, idempotency, retry handling, and queue processing
tRPC End-to-End Type Safety
tRPC router, procedures, React Query integration, and error handling
Real-Time Updates with SSE
EventSource API, reconnection, broadcasting, and Express/Fastify SSE