File Upload in Boilerplates: UploadThing vs S3 vs Cloudinary in 2026
TL;DR
UploadThing for most boilerplates: TypeScript-first, integrates seamlessly with Next.js, generous free tier. Cloudflare R2 for cost-sensitive apps at scale (zero egress fees). Cloudinary when image transformation (resize, crop, format conversion) is a core product feature. AWS S3 only when deep AWS integration or compliance requires it.
The Upload Landscape
| Service | Free Tier | Paid | Type-Safe | Image Transform | Egress |
|---|---|---|---|---|---|
| UploadThing | 2GB/month | $10/mo | ✅ TypeScript | ❌ | Included |
| Cloudflare R2 | 10GB/month | $0.015/GB | Via SDK | ❌ | Free |
| Cloudinary | 25GB storage | $89/mo | Partial | ✅ Excellent | Included |
| AWS S3 | 5GB/12mo | $0.023/GB | Via SDK | ❌ | $0.09/GB |
| Supabase Storage | 1GB | $25/mo | Via SDK | ❌ | Included |
UploadThing: The Developer-First Choice
UploadThing is built specifically for Next.js. The API is TypeScript-native, the SDK handles chunked uploads, and the configuration is minimal.
// lib/uploadthing.ts — define upload routes
import { createUploadthing, type FileRouter } from 'uploadthing/next';
import { auth } from '@clerk/nextjs/server';
const f = createUploadthing();
export const ourFileRouter = {
// Profile avatar — authenticated, max 4MB image
profileAvatar: f({ image: { maxFileSize: '4MB' } })
.middleware(async () => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
// Update user avatar in database
await prisma.user.update({
where: { id: metadata.userId },
data: { avatarUrl: file.url },
});
return { url: file.url };
}),
// Document upload — PDF, max 16MB
document: f({ pdf: { maxFileSize: '16MB' } })
.middleware(async () => {
const { userId } = await auth();
if (!userId) throw new Error('Unauthorized');
return { userId };
})
.onUploadComplete(async ({ metadata, file }) => {
await prisma.document.create({
data: { userId: metadata.userId, url: file.url, name: file.name }
});
}),
} satisfies FileRouter;
// app/api/uploadthing/route.ts
import { createRouteHandler } from 'uploadthing/next';
import { ourFileRouter } from '@/lib/uploadthing';
export const { GET, POST } = createRouteHandler({ router: ourFileRouter });
// Client component — drag and drop upload
'use client';
import { UploadButton } from '@uploadthing/react';
export function AvatarUpload({ onUpload }: { onUpload: (url: string) => void }) {
return (
<UploadButton
endpoint="profileAvatar"
onClientUploadComplete={(res) => onUpload(res[0].url)}
onUploadError={(error) => toast.error(error.message)}
/>
);
}
That's the entire implementation. Auth, chunked upload, database update, file type validation — all configured in ~30 lines.
Cloudflare R2: Cost-Optimized Storage
R2 is S3-compatible with zero egress fees — the main cost differentiator at scale.
// lib/r2.ts
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
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!,
},
});
// Generate presigned URL for direct browser upload
export async function getUploadUrl(key: string, contentType: string) {
const command = new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
ContentType: contentType,
});
return getSignedUrl(r2, command, { expiresIn: 300 }); // 5 minutes
}
// Generate presigned URL for download
export async function getDownloadUrl(key: string) {
const command = new GetObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: key,
});
return getSignedUrl(r2, command, { expiresIn: 3600 }); // 1 hour
}
// API route — generate upload URL
export async function POST(req: Request) {
const { userId } = await auth();
const { fileName, contentType } = await req.json();
const key = `uploads/${userId}/${Date.now()}-${fileName}`;
const uploadUrl = await getUploadUrl(key, contentType);
return Response.json({ uploadUrl, key });
}
// Client — upload directly to R2 from browser
async function uploadFile(file: File) {
const { uploadUrl, key } = await fetch('/api/upload', {
method: 'POST',
body: JSON.stringify({ fileName: file.name, contentType: file.type }),
}).then(r => r.json());
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
});
// Notify backend that upload is complete
await fetch('/api/upload/complete', {
method: 'POST',
body: JSON.stringify({ key, fileName: file.name }),
});
}
R2 pricing: $0.015/GB storage, $0 egress. At 100GB storage with heavy download traffic, R2 is dramatically cheaper than S3.
Cloudinary: When Transformations Matter
Cloudinary excels when you need server-side image transformations:
import { v2 as cloudinary } from 'cloudinary';
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
// Upload and generate transformations
const result = await cloudinary.uploader.upload(filePath, {
folder: 'product-images',
public_id: `product-${productId}`,
transformation: [
{ width: 800, height: 800, crop: 'fill' }, // Square crop
{ quality: 'auto', fetch_format: 'auto' }, // Auto WebP/AVIF
],
});
// URL-based transformations — no pre-processing needed
const thumbnailUrl = cloudinary.url(result.public_id, {
width: 150,
height: 150,
crop: 'thumb',
gravity: 'face', // Smart face detection for avatars
});
const heroUrl = cloudinary.url(result.public_id, {
width: 1200,
quality: 80,
fetch_format: 'auto',
});
Choose Cloudinary when:
- Products selling physical goods (need multiple image sizes, crops)
- Avatar/profile photos (smart cropping, face detection)
- Content platforms with user-generated images
- Need real-time image transformations via URL parameters
Boilerplate Upload Defaults
| Boilerplate | File Upload | Default Provider |
|---|---|---|
| ShipFast | ✅ | UploadThing |
| Supastarter | ✅ | Supabase Storage |
| Makerkit | ✅ | Supabase / Firebase Storage |
| T3 Stack | ❌ (add yourself) | Community: UploadThing |
| Open SaaS | ✅ | AWS S3 |
Decision Guide
Starting a new Next.js SaaS?
→ UploadThing (least setup, TypeScript-native)
High-volume storage where egress costs matter?
→ Cloudflare R2 (zero egress fees)
Need image transformations (resize, crop, format)?
→ Cloudinary
Already on Supabase and want everything in one place?
→ Supabase Storage
Need compliance (SOC2, HIPAA) or enterprise features?
→ AWS S3 (most mature compliance certifications)
Find boilerplates with file upload solutions on StarterPick.
Check out this boilerplate
View UploadThing on StarterPick →