Skip to main content

File Upload in Boilerplates: UploadThing vs S3 vs Cloudinary in 2026

·StarterPick Team
uploadthings3cloudinaryfile-upload2026

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

ServiceFree TierPaidType-SafeImage TransformEgress
UploadThing2GB/month$10/mo✅ TypeScriptIncluded
Cloudflare R210GB/month$0.015/GBVia SDKFree
Cloudinary25GB storage$89/moPartial✅ ExcellentIncluded
AWS S35GB/12mo$0.023/GBVia SDK$0.09/GB
Supabase Storage1GB$25/moVia SDKIncluded

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

BoilerplateFile UploadDefault Provider
ShipFastUploadThing
SupastarterSupabase Storage
MakerkitSupabase / Firebase Storage
T3 Stack❌ (add yourself)Community: UploadThing
Open SaaSAWS 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 →

Comments