Skip to main content

UploadThing vs S3 vs Cloudflare R2 for SaaS Boilerplates 2026

·StarterPick Team
uploadthingaws-s3cloudflare-r2file-uploadsaas-boilerplatenextjs2026

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

ScenarioRecommended
Quick setup in Next.jsUploadThing
Already on AWSS3
Cost-sensitive, high egressCloudflare R2
Need S3 ecosystem compatibilityS3 or R2 (same API)
Profile pictures, small filesUploadThing
Large video files, course contentR2 (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.

Comments