UploadThing vs S3 vs Cloudflare R2 for SaaS (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' },
],
},
};
Limitations and When to Skip Each Option
Every option has scenarios where it fails the requirements. Understanding the limits helps you avoid choosing the wrong tool and discovering it late.
UploadThing's limitations become apparent at scale and in advanced use cases. The free tier's 100 uploads per day is a hard ceiling that can block early launch if you have a viral moment — you'll need to upgrade quickly or find users locked out. More significantly, UploadThing doesn't give you direct control over your storage backend's configuration: you can't set custom bucket policies, configure cross-region replication, or integrate with enterprise compliance scanning tools. For SaaS products that need HIPAA Business Associate Agreements or SOC2 compliance for file storage, UploadThing's current offering doesn't satisfy those requirements — you need S3 with a direct AWS BAA.
AWS S3's biggest limitation is the egress cost model. For applications where files are frequently downloaded — image-heavy content platforms, video hosting, document sharing SaaS — S3 egress charges of $0.09/GB can become a significant cost center before your revenue covers it. A SaaS serving 10TB of downloads per month pays $900 in S3 egress alone. S3's complexity is also a real cost: IAM policies, bucket policies, CORS configuration, and presigned URL expiry management require knowledge that not every developer has. The setup surface is larger than UploadThing's 30-line integration.
Cloudflare R2's main limitation is ecosystem maturity. R2 launched in 2022 and is still building out features that S3 has had for years — event notifications, batch operations, and advanced lifecycle policies are either missing or limited. R2 public buckets use Cloudflare's CDN automatically, which is a benefit for most use cases but a constraint if you need a different CDN provider. R2 also doesn't have the depth of third-party integrations that S3 has: some backup tools, compliance scanners, and analytics platforms only support S3.
Making the Final Decision
The right choice for most Next.js SaaS projects follows a simple hierarchy. Start with UploadThing if you're pre-revenue or at early stage — the developer experience and free tier are excellent, and you can migrate to S3 or R2 later when you have specific reasons to switch. The migration path is real work but not catastrophic: UploadThing stores files at accessible URLs, so migrating means updating where new uploads go and optionally migrating existing files.
Switch to Cloudflare R2 when your monthly egress exceeds $50. At that point, R2's zero egress charges generate real savings and the S3-compatible API means your existing S3 code needs minimal changes — update the endpoint URL and credentials, not your upload logic.
Choose S3 from the start if you're in a regulated industry, already on AWS infrastructure, or need specific S3 features like Object Lambda, S3 Intelligent-Tiering, or third-party integrations with legacy tools. S3's enterprise tooling ecosystem is genuinely larger than R2's and will remain so for several years.
For boilerplate selection, check which file storage option is pre-configured before purchase. Most boilerplates using UploadThing will have the full React components and server-side route handlers already wired up — switching to R2 or S3 means deleting that code and implementing the presigned URL flow yourself. If you know you want R2 or S3 from day one, look for boilerplates that ship with those configured or that have clean enough separation to make the swap easy.
File Security Patterns That Apply to All Three Options
Regardless of which storage solution you choose, the same security patterns apply. Getting these right prevents unauthorized access to user files.
Never serve files directly from storage URLs in responses without checking authorization first. If your S3, R2, or UploadThing URLs are publicly guessable (sequential IDs, predictable paths), any user who discovers the URL pattern can access any user's files. The correct approach: store only the file key (path) in your database, not the public URL. Generate a signed URL on demand when a user requests their file, after verifying they have permission to access it. The signed URL expires after a short window (15-60 minutes), so leaked URLs don't provide permanent access.
File type validation must happen server-side. Client-side file type validation (checking file.type in JavaScript) is trivially bypassed — a malicious actor can rename an executable as image.png and upload it. Server-side validation checks the actual file content (magic bytes) using a library like file-type to verify the content matches the declared MIME type. UploadThing's server-side file router handles this automatically with its { image: { maxFileSize: '2MB' } } configuration. S3 and R2 require you to implement this validation in your presigned URL generation logic.
File size limits protect you from cost surprises. A SaaS with no upload size limit is vulnerable to storage cost bombs — malicious or mistaken uploads of huge files that generate significant storage and egress charges before you notice. Set reasonable limits for each file type (profile images: 2MB, documents: 10MB, video: 100MB) and enforce them both on the client (for UX feedback) and server (for security).
Organize files by user ID in your storage prefix. A key pattern like uploads/{userId}/{filename} rather than just uploads/{filename} makes access control simpler and bucket policies more targeted. It also makes GDPR deletion straightforward — delete all objects with prefix uploads/{userId}/ to remove all files for a user without scanning the entire bucket.
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.
The upload infrastructure choice compounds over time: migrating from UploadThing to S3 after launch requires updating every upload endpoint, storage reference, and signed URL implementation. Make the right choice for your scale and cost profile before shipping, not after user data is already stored.
UploadThing's Next.js-native integration eliminates the CORS configuration and signed URL management that make direct S3 implementations fragile. For most SaaS products, the developer experience improvement justifies the per-upload cost over self-managing S3.
See how file storage fits into the full SaaS stack: The ideal tech stack for SaaS in 2026.
Compare infrastructure choices including databases: Best SaaS boilerplates with Neon and PlanetScale 2026.
Find the best production-ready SaaS boilerplates: Best SaaS boilerplates 2026.