Skip to content
·11 min read

File Upload and Storage With S3, R2, and Supabase Storage

How to let users upload files safely and store them affordably in your AI-built application

Share

File upload and storage is one of those features that looks trivial until you actually build it. A form, an input, a button. How hard can it be? Then you discover CORS policies, file size limits, storage costs, and the joy of someone trying to upload a 2GB executable disguised as a profile picture. 92% of developers now use AI tools daily, and file uploads are one of the features where AI-generated code gets you 80% of the way there but the remaining 20% (security, cost, architecture) separates production apps from demos.

Think of cloud storage like a storage locker facility. Amazon S3 is the giant warehouse on the edge of town, it has been around forever and every moving company knows how to work with it. Cloudflare R2 is the warehouse with free delivery, same storage but you never pay for pickups. Supabase Storage is the locker attached to your apartment, smaller and simpler but already part of your building. Where you store your files matters less than how you get them in and out safely.

How Presigned URLs Work

The biggest architectural decision in file upload is whether files pass through your server or go directly to storage. For anything beyond toy projects, direct upload is the answer. Your server generates a short-lived, presigned URL that gives the client temporary permission to upload a file directly to S3, R2, or Supabase Storage. The file never touches your server, which means your API stays fast and your hosting bill stays low.

The flow is simple. Your client requests an upload URL from your API. Your API generates a presigned URL with constraints (file type, max size, expiration) and returns it. The client uploads directly to cloud storage using that URL. Once complete, the client notifies your API with the file's location for database storage.

In S3 terms, you call createPresignedPost or getSignedUrl with a putObject command. R2 uses the exact same S3-compatible API, so the code is identical. Supabase Storage has its own SDK method, createSignedUploadUrl, that achieves the same result with less configuration.

// Works for both S3 and R2 (S3-compatible API)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const client = new S3Client({
  region: "auto",
  endpoint: process.env.STORAGE_ENDPOINT,
  credentials: {
    accessKeyId: process.env.ACCESS_KEY_ID!,
    secretAccessKey: process.env.SECRET_ACCESS_KEY!,
  },
});

export async function generateUploadUrl(
  fileName: string,
  contentType: string
) {
  const key = `uploads/${Date.now()}-${fileName}`;
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: key,
    ContentType: contentType,
  });

  const url = await getSignedUrl(client, command, {
    expiresIn: 600, // 10 minutes
  });

  return { url, key };
}

The expiresIn value is important. Ten minutes gives users enough time to upload even on slow connections, but limits the window for abuse. If someone intercepts the URL, it becomes useless after the expiration.

EXPLAINER DIAGRAM: A sequence diagram with three columns labeled CLIENT, YOUR API, and CLOUD STORAGE. Step 1 shows an arrow from CLIENT to YOUR API labeled REQUEST UPLOAD URL with a small note showing file name and type. Step 2 shows YOUR API generating a presigned URL with a clock icon showing 10 MIN EXPIRY. Step 3 shows an arrow from YOUR API back to CLIENT labeled RETURN PRESIGNED URL. Step 4 shows a thick arrow from CLIENT directly to CLOUD STORAGE labeled UPLOAD FILE DIRECTLY with a note NO SERVER MIDDLEMAN. Step 5 shows a dashed arrow from CLIENT to YOUR API labeled CONFIRM UPLOAD with the file key. A red X between CLIENT and YOUR API on step 4 emphasizes that the file does not pass through the API.
Direct upload via presigned URLs keeps your server lean and your uploads fast.

Security That Actually Matters

File uploads are one of the most common attack vectors in web applications. The good news is that the critical validations are straightforward. The bad news is that skipping even one of them can be catastrophic.

Validate file types on both sides. Client-side validation improves UX (instant feedback when someone picks a .exe instead of a .png). Server-side validation prevents attacks. When generating the presigned URL, restrict the ContentType to your allowed MIME types. Never trust the file extension alone because renaming malware.exe to malware.png takes two seconds.

Enforce size limits. Set a Content-Length condition on your presigned URL. For S3 and R2, use presigned POST policies with conditions like ["content-length-range", 0, 5242880] to cap uploads at 5MB. Supabase Storage lets you set size limits per bucket in the dashboard.

Sanitize file names. Strip special characters, path traversal attempts (../../../etc/passwd), and excessively long names. Generate your own key using a timestamp and random string instead of trusting user-provided names. The code example above already does this with the Date.now() prefix.

Set your buckets to private by default. Use presigned download URLs when you need to serve files, or put a CDN in front of your bucket with proper cache headers. You would not leave your storage locker door open just because it is easier than carrying a key.

// Server-side validation before generating the presigned URL
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

export function validateUpload(contentType: string, fileSize: number) {
  if (!ALLOWED_TYPES.includes(contentType)) {
    throw new Error("File type not allowed");
  }
  if (fileSize > MAX_SIZE) {
    throw new Error("File exceeds maximum size of 5MB");
  }
}
Key Takeaway

Always validate file uploads on the server, not just the client. Client-side checks improve user experience but provide zero security. Your presigned URL generation endpoint should enforce file type restrictions, size limits, and rate limiting before handing out upload permissions. Treat every upload request as potentially malicious.

Setting Up Each Storage Provider

All three providers follow the same general pattern, but the setup details differ. Here is what you need to know for each one.

Amazon S3 is the default choice when you have no strong opinion. Create a bucket, set up an IAM user with scoped permissions (never use root credentials), configure CORS to allow uploads from your domain, and point your S3Client at the standard AWS endpoint. S3 charges for storage, requests, and egress (data leaving AWS). That egress cost is the big one.

Cloudflare R2 is S3-compatible with one critical difference: zero egress fees. Same API, same SDK, same presigned URL flow, but you never pay for downloads. Back to the storage locker analogy, R2 is the warehouse with free delivery. You pay for space and for putting stuff in, but taking it out costs nothing. For apps that serve user-uploaded content (profile pictures, documents, media), R2 can cut your storage bill by 50-80% compared to S3. Setup requires creating a bucket in the Cloudflare dashboard, generating an API token, and pointing your S3Client at https://<account-id>.r2.cloudflarestorage.com.

Supabase Storage is the simplest option if you are already on Supabase. It is the locker attached to your apartment. Create a bucket, set access policies using row-level security, and use the Supabase JS client. No separate SDK, no IAM configuration, no CORS headers to debug.

// Supabase Storage upload (client-side)
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

const { data, error } = await supabase.storage
  .from("uploads")
  .upload(`public/${fileName}`, file, {
    contentType: file.type,
    upsert: false,
  });

The tradeoff is flexibility. Supabase Storage has fewer configuration options and locks you into the ecosystem. For most indie projects, that is fine. For larger applications that need fine-grained CDN control or multi-region replication, S3 or R2 gives you more room.

Building Your First AI-Powered App?

Learn the foundational patterns that make features like file upload click.

Start with the basics

Cost Comparison for Real Projects

Cost is where these three providers diverge the most, and it is where the wrong choice can quietly drain your budget.

S3 pricing runs about $0.023/GB for storage and $0.09/GB for egress. Store 100GB with 500GB of monthly downloads and you pay $2.30 for storage plus $45 for egress. The egress cost is almost 20x the storage cost.

R2 pricing is $0.015/GB for storage with zero egress fees. That same workload costs $1.50 total, a 97% reduction. R2 also includes 10GB of free storage and 10 million free reads per month, which covers most indie projects entirely.

Supabase Storage includes 1GB free on the free tier and $0.021/GB on Pro. Egress is included in your plan limits. For small projects, it is effectively free.

For most indie hackers, R2 is the best value. S3 compatibility means migration is trivial if you outgrow it. If you are all-in on Supabase, use Supabase Storage to keep your stack simple. Pick S3 only when you need specific AWS integrations like Lambda triggers on upload.

Common Mistake

Choosing S3 by default without checking your egress patterns. Many developers pick S3 because it is the industry standard, then get surprised by bandwidth bills when users start downloading files. If your app serves uploaded files back to users, calculate your expected egress before committing. Switching to R2 later is easy because the API is compatible, but switching early saves you from an ugly billing surprise in month three.

Putting It All Together

Here is the implementation pattern that works across all three providers. Build an API route that validates the upload request, generates a presigned URL, and returns it to the client. Build a client-side upload component that requests the URL, uploads directly to storage, and confirms completion.

EXPLAINER DIAGRAM: A comparison table with three columns labeled S3, R2, and SUPABASE STORAGE and five rows. Row 1 SETUP COMPLEXITY shows S3 as HIGH with IAM and CORS noted, R2 as MEDIUM with API token noted, and Supabase as LOW with dashboard toggle noted. Row 2 EGRESS COST shows S3 as $0.09 PER GB in red, R2 as FREE in green, and Supabase as INCLUDED IN PLAN in green. Row 3 S3 COMPATIBLE shows S3 as YES, R2 as YES, and Supabase as NO with OWN SDK noted. Row 4 BEST FOR shows S3 as AWS ECOSYSTEM, R2 as READ-HEAVY APPS, and Supabase as SUPABASE PROJECTS. Row 5 FREE TIER shows S3 as 5GB 12 MONTHS, R2 as 10GB FOREVER, and Supabase as 1GB.
Quick comparison of the three storage providers for file upload workloads.

On the client, show a progress bar during upload, validate file type and size before requesting a URL, and handle errors gracefully. A failed upload with no feedback is worse than no upload feature at all.

For the database, store the file key (not the full URL) alongside a reference to the user who uploaded it. Generate the full URL on read using your CDN domain or a presigned download URL. This lets you migrate between storage providers without updating every database record.

Rate limiting is the final piece. Without it, a bot can generate thousands of presigned URLs and fill your bucket with garbage. Add rate limiting to your upload endpoint through your API framework's middleware, Upstash Redis, or Cloudflare's built-in rate limiting.

Ready to Ship Your Storage Feature?

Build file uploads this weekend and ship on Monday.

Start building

What This Means For You

File upload and storage is a solved problem with well-documented patterns. The choice between S3, R2, and Supabase Storage comes down to your existing stack and your cost sensitivity.

  • If you are a senior developer building a production app: Use presigned URLs with direct upload from day one. It scales better, costs less, and keeps your API free from processing file bytes. Start with R2 unless you have a specific reason to pick S3, and add file validation at both the client and server layers before you ship.
  • If you are an indie hacker launching fast: Pick whichever storage is already in your stack. Using Supabase? Use Supabase Storage. Using Cloudflare? Use R2. Do not add a new service just for file uploads when your existing tools handle it. Get the security basics right (type validation, size limits, private buckets) and ship.
PJ
Pranay Joshi

20+ years building products at scale. VP of Product & Engineering, startup founder, and AI coach. Helping dreamers turn ideas into reality with vibe coding.

The Tuesday Shipping Report

Every Tuesday, one focused email:

  • - The tool or technique that's actually working right now
  • - A real problem from the community (and how to solve it)
  • - What changed this week in the vibe coding landscape

Read by 1,000+ founders, developers, and creators building with AI. Free forever. No spam.