UploadThing vs S3 vs Cloudflare R2 for SaaS Boilerplates 2026
File Uploads: The Overlooked Infrastructure Decision
Every SaaS that accepts user-generated content needs file storage. Profile images, document uploads, generated assets, video files — these cannot live in your database. You need object storage.
In 2026, three options dominate for Next.js SaaS:
- UploadThing — developer-friendly SaaS built specifically for Next.js
- AWS S3 — the original object storage, maximum ecosystem
- Cloudflare R2 — S3-compatible, zero egress fees, cheaper at scale
The right choice depends on your team's experience, upload volume, and budget.
TL;DR
- UploadThing: Choose for fast setup in Next.js. Handles presigned URLs, client uploads, and webhooks. $0 to start.
- AWS S3: Choose for maximum ecosystem compatibility, when you are already on AWS, or need advanced features.
- Cloudflare R2: Choose for cost-sensitive apps with high egress traffic. Zero egress fees is the key advantage.
Key Takeaways
- UploadThing reduces file upload implementation to ~30 lines of code for Next.js
- S3 egress fees are $0.09/GB — for a SaaS with image-heavy content, this adds up significantly
- Cloudflare R2 charges $0/GB egress — S3-compatible API means minimal migration effort
- UploadThing free tier: 2GB storage, 100 uploads — sufficient for development
- All three provide presigned URLs for secure direct browser-to-storage uploads (bypassing your server)
- Most SaaS boilerplates default to UploadThing or S3; R2 is less commonly pre-configured
UploadThing: Fastest Setup
UploadThing is built specifically for the Next.js ecosystem. It abstracts away presigned URL management, client-side upload logic, and file validation.
npm install uploadthing @uploadthing/react
Server Configuration
// app/api/uploadthing/core.ts
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@/lib/auth';
const f = createUploadthing();
export const uploadRouter = {
// Profile picture: 2MB limit, image only
profilePicture: f({ image: { maxFileSize: '2MB', maxFileCount: 1 } })
.middleware(async () => {
const session = await auth();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
await db.user.update({
where: { id: metadata.userId },
data: { avatarUrl: file.url },
});
return { uploadedBy: metadata.userId };
}),
// Document upload: 16MB, PDF/Word
documentUpload: f({
pdf: { maxFileSize: '16MB' },
'application/msword': { maxFileSize: '16MB' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { maxFileSize: '16MB' },
})
.middleware(async () => {
const session = await auth();
if (!session) throw new Error('Unauthorized');
return { userId: session.user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
const doc = await db.document.create({
data: {
userId: metadata.userId,
name: file.name,
url: file.url,
size: file.size,
},
});
return { documentId: doc.id };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof uploadRouter;
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { uploadRouter } from './core';
export const { GET, POST } = createRouteHandler({ router: uploadRouter });
Client Usage
// components/ProfilePictureUpload.tsx
'use client';
import { UploadButton } from '@uploadthing/react';
import type { OurFileRouter } from '@/app/api/uploadthing/core';
export function ProfilePictureUpload() {
return (
<UploadButton<OurFileRouter, 'profilePicture'>
endpoint="profilePicture"
onClientUploadComplete={(res) => {
// res[0].url is the file URL
console.log('Uploaded:', res[0].url);
window.location.reload();
}}
onUploadError={(error) => {
alert(`Error: ${error.message}`);
}}
/>
);
}
UploadThing pricing: free up to 2GB storage; $10/mo for 100GB. Transparent pricing at uploadthing.com.
AWS S3: The Standard
S3 is the most widely supported object storage. Every AWS service, CDN, and tool integrates with it.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/s3.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
// Generate presigned URL for client-side upload:
export async function generateUploadUrl(
key: string,
contentType: string,
expiresIn = 3600
): Promise<string> {
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, { expiresIn });
}
// Generate presigned URL for download:
export async function generateDownloadUrl(key: string, expiresIn = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn });
}
// Upload flow with S3:
// 1. Client requests presigned URL from your API:
export async function POST(req: Request) {
const session = await auth();
if (!session) return new Response('Unauthorized', { status: 401 });
const { fileName, contentType } = await req.json();
const key = `uploads/${session.user.id}/${Date.now()}-${fileName}`;
const presignedUrl = await generateUploadUrl(key, contentType);
return Response.json({
presignedUrl,
key,
fileUrl: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`,
});
}
// 2. Client uploads directly to S3 (no server bandwidth used):
const { presignedUrl, key, fileUrl } = await fetch('/api/upload', {
method: 'POST',
body: JSON.stringify({ fileName: file.name, contentType: file.type }),
}).then(r => r.json());
await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// 3. Client tells your server the upload is complete:
await fetch('/api/upload/complete', {
method: 'POST',
body: JSON.stringify({ key }),
});
S3 Cost
- Storage: $0.023/GB/month
- PUT requests: $0.005/1000
- Egress (data out): $0.09/GB — this adds up for image-heavy SaaS
Cloudflare R2: Zero Egress
Cloudflare R2 is S3-compatible with one major difference: $0 egress fees.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# Same SDK as S3! R2 is S3-compatible.
// lib/r2.ts
import { S3Client } from '@aws-sdk/client-s3';
export const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
// Identical API to S3 — swap s3 for r2 in all functions
export async function generateUploadUrl(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: contentType,
});
return getSignedUrl(r2, command, { expiresIn: 3600 });
}
Cloudflare R2 public bucket URL: https://pub-{your-account}.r2.dev/{key} or custom domain.
R2 Cost
- Storage: $0.015/GB/month (cheaper than S3)
- PUT requests: $0.0045/1000
- Egress: $0/GB — the key advantage
R2 vs S3 Cost Example
For a SaaS serving 100GB/month in image downloads:
- S3: $0.09 × 100 = $9/month in egress alone
- R2: $0 egress
At 1TB/month egress: S3 = $90/month; R2 = $0.
When to Use Each
| Scenario | Recommended |
|---|---|
| Quick setup in Next.js | UploadThing |
| Already on AWS | S3 |
| Cost-sensitive, high egress | Cloudflare R2 |
| Need S3 ecosystem compatibility | S3 or R2 (same API) |
| Profile pictures, small files | UploadThing |
| Large video files, course content | R2 (zero egress) |
| Compliance requirements (HIPAA, etc.) | S3 (certifications) |
Image CDN on Top of Storage
For serving images efficiently, add a CDN layer:
// Cloudflare Images (with R2 backend):
const imageUrl = `https://imagedelivery.net/${process.env.CF_ACCOUNT_HASH}/${fileId}/public`;
// With transformations:
const thumbnailUrl = `https://imagedelivery.net/${process.env.CF_ACCOUNT_HASH}/${fileId}/thumbnail`;
// "thumbnail" is a named variant defined in Cloudflare dashboard
// Next.js Image with S3/R2:
// next.config.js:
module.exports = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: '*.r2.dev' },
{ protocol: 'https', hostname: '*.s3.amazonaws.com' },
{ protocol: 'https', hostname: 'imagedelivery.net' },
],
},
};
Methodology
Based on publicly available pricing and documentation from UploadThing, AWS S3, and Cloudflare R2 as of March 2026.
Building a SaaS with file uploads? StarterPick helps you find boilerplates pre-configured with the right file storage solution.