TrueSpec

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

Advanced

Secure Webhook Handler

Signature verification, idempotency, retry handling, and queue processing

API & Backend
Intermediate

tRPC End-to-End Type Safety

tRPC router, procedures, React Query integration, and error handling

API & Backend
Intermediate

Real-Time Updates with SSE

EventSource API, reconnection, broadcasting, and Express/Fastify SSE

API & Backend