ihatereading.in has more than 300 articles. The text sits in Firestore. I wrote about that pipeline when I generated summaries and embeddings for 300 articles. The text was never the hard part. The hard part is everything that rides along with it: banners, inline diagrams, carousel slides, screenshots, the odd PDF, and the readers who load all of it again and again.
Text is cheap. A 2,000-word article is around 12 KB. One banner PNG at 2x resolution is 300 KB to 2 MB. Multiply that by 300 posts and a few images each, then by every visitor who opens a page, and storage stops being a line item you ignore.
So here is the short version of my setup. Article text goes in the database. Files go to UploadThing in my React and Next.js apps. I keep files out of Vercel's storage, Supabase Storage and Firebase Storage, because their free tiers are built for prototypes and their paid tiers bill for every gigabyte people download. For a free, content-heavy, static website, that model works against you.
This post covers why, then lists the SDKs and storage services I'd look at today. They're grouped for backend developers, frontend developers, mobile developers and design engineers. Every repository below was checked on GitHub this week for its license and last commit. Every price except AWS's was read off the vendor's own pricing page on 17 October 2026. Prices change, so click through before you commit.
Table of contents
- Why a free static site gets a storage bill
- Why I picked UploadThing for ihatereading
- Why storage SDKs matter more than the bucket
- The pricing table: 11 options, two traffic levels
- Backend developers: Python and Node SDKs
- Frontend developers: React and Next.js upload kits
- Mobile developers: Expo and React Native
- Design engineers: image CDNs, resizing and placeholders
- Self-hosted S3 servers (and the MinIO problem)
- Backends that ship file storage built in
- How to pick in five minutes
- Estimate the bill before it arrives: StackBudget
1. Why a free static site gets a storage bill
A static website looks free. You build HTML once, push it to a CDN, and every visitor gets the same files. Hosting a static site really is free on most platforms. The bill doesn't come from hosting. It comes from three meters that run quietly behind the static pages.
Meter one: data transfer, also called egress or bandwidth. Every time a reader's browser downloads an image from your bucket, you pay for those bytes leaving the provider's network. Storage is cheap: a few cents per gigabyte per month almost everywhere. Egress is where the money goes. A 1 MB banner stored once costs a fraction of a cent. The same banner downloaded 100,000 times is 100 GB of transfer.
Meter two: operations. Most object stores charge per request: per upload, per list, per read. These are usually priced per million. They stay tiny until something like an image-heavy homepage or a scraper hammering your sitemap turns them into real numbers.
Meter three: free-tier walls. This one bites indie builders the hardest. Some platforms don't charge you more when you go over the free tier. They switch your files off instead.
Vercel's own Blob docs are honest about this. On the Hobby plan, Vercel Blob includes 1 GB of storage, 10,000 simple operations, 2,000 advanced operations and 10 GB of data transfer per month. If you go over, Vercel says you won't be charged, but access is blocked for 30 days. Ten gigabytes is about 10,000 downloads of a 1 MB banner. One post that does well on Hacker News can use that up in an afternoon. After that, every image on your site is broken for a month.
The fix Vercel offers is the Pro plan, where Blob is billed per use. The docs list storage at $0.023/GB-month, simple operations at $0.40 per million, advanced operations at $5 per million and data transfer at $0.05/GB in the iad1 region. That's on top of the Pro seat. Their own worked example, a store with 50 GB and 2.5 million downloads, lands at $26.93 a month. That's reasonable for a SaaS with paying users. It's a strange cost for a free blog that makes no money from its readers.
Supabase and Firebase follow the same pattern with different numbers:
- Supabase Storage on the Free plan gives you 1 GB of storage, a 50 MB maximum file size and 5 GB of egress. Pro is $25/month and includes 100 GB of storage (then $0.0213/GB) and 250 GB of egress (then $0.09/GB).
- Firebase Cloud Storage on the pricing page shows "Not applicable" for storage on the Spark column for new projects. Newer
*.firebasestorage.appbuckets give you 5 GB-months stored and 100 GB downloaded per month at no cost, but only in us-central1, us-west1 and us-east1. Legacyappspot.combuckets list $0.026/GB stored and $0.12/GB downloaded past the free quota.
None of these services are bad. They're good for apps where files belong to users who pay you: avatars, invoices, uploaded documents. They're a poor fit for a publication, where the files belong to you and are downloaded by people who pay nothing.
That difference shaped every choice in this post. I wrote about the wider budget in how we're keeping ihatereading.in under $100/month at 10k visitors. Storage is one of the few line items that grows with every new reader, so it needs the most care.
2. Why I picked UploadThing for ihatereading
I use UploadThing in my React apps for one reason: it charges for what I store, not for what readers download.
Here's what the pricing page says today:
| Plan | Price | Storage | Downloads |
|---|---|---|---|
| Free | $0 | 2 GB (shared across all your apps) | Unlimited uploads and downloads |
| 100GB | $10/month | 100 GB, plus Regions and Private Files | Unlimited |
| Usage-based | $25/month | 250 GB included, then $0.08/GB | Unlimited |
For a site like ihatereading, that removes the scary meter. If an article goes viral, the downloads don't change the invoice. The only number I need to watch is total gigabytes stored. I control that by compressing images before upload, which I'd do anyway for page speed.
The second reason is the developer experience. UploadThing splits the work into two halves:
- Your server decides who can upload what, and how large it can be.
- UploadThing's servers receive the bytes. Your server never handles the upload stream. That matters on serverless platforms, where request bodies are size-limited and function time is billed.
You define a "file router" on the server. Each route says which file types it accepts, the size limits, an auth check, and what to do when an upload finishes. This is close to what the official Next.js App Router example in the repository does:
// src/server/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/next";
const f = createUploadthing();
export const uploadRouter = {
articleBanner: f({
image: { maxFileSize: "4MB", maxFileCount: 1, acl: "public-read" },
})
.middleware(async ({ req }) => {
const user = await getAdminFromRequest(req); // your own auth check
if (!user) throw new Error("Unauthorized");
return { userId: user.id };
})
.onUploadComplete(async ({ file, metadata }) => {
// save file.ufsUrl / file.key next to the article in your database
console.log("banner uploaded by", metadata.userId, file.key);
}),
} satisfies FileRouter;
export type OurFileRouter = typeof uploadRouter;// src/app/api/uploadthing/route.ts
import { createRouteHandler } from "uploadthing/next";
import { uploadRouter } from "~/server/uploadthing";
export const { GET, POST } = createRouteHandler({ router: uploadRouter });// src/utils/uploadthing.ts
import { generateUploadButton, generateUploadDropzone } from "@uploadthing/react";
import type { OurFileRouter } from "~/server/uploadthing";
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();// anywhere in your editor UI
"use client";
import { UploadButton } from "~/utils/uploadthing";
export function BannerUpload() {
return (
<UploadButton
endpoint="articleBanner"
onClientUploadComplete={(res) => console.log("done", res[0].ufsUrl)}
onUploadError={(e) => alert(e.message)}
/>
);
}That's the whole integration. The types flow from the router to the button, so if I rename articleBanner, TypeScript flags every component that still uses the old name.
For scripts and backfills there's UTApi, a server-side client with uploadFiles, uploadFilesFromUrl, listFiles, deleteFiles and generateSignedURL. That's how you'd move an existing folder of banners in one go:
import { UTApi } from "uploadthing/server";
const utapi = new UTApi(); // reads UPLOADTHING_TOKEN from env
// pull images that already live somewhere public and re-home them
const results = await utapi.uploadFilesFromUrl([
"https://example.com/old-banner-1.png",
"https://example.com/old-banner-2.png",
]);The repository is MIT-licensed and active: its last commit was on 30 September 2026. It ships packages for React, Vue, Svelte, Solid, Nuxt and Expo, so the same file router can back a web dashboard and a phone app.
What UploadThing is not good at: It's a hosted service, not open-source storage you can run yourself. The free 2 GB is shared across every app on your account, not per project. I haven't found an official Python SDK, so Python backends either call its REST API or leave uploads to the JavaScript side. If you need full control of the bucket, or you have terabytes, the S3-compatible options below make more sense.
3. Why storage SDKs matter more than the bucket
Most "best storage" lists compare buckets. In practice, the bucket is the easy part. Most providers now speak the S3 API, so the bucket is a commodity. The code you write around the bucket is what costs you weeks.
A good storage SDK handles six jobs you'd otherwise hand-write:
- Presigned uploads. The browser uploads straight to the bucket using a short-lived signed URL. Your server only signs. This keeps large files away from your serverless functions, which have body-size limits and per-millisecond billing.
- Validation and auth. File-type and size checks have to happen on the server. A client-side
accept="image/*"stops nobody. - Multipart and resumable uploads. A 2 GB video on hotel Wi-Fi will fail. Multipart upload splits it into chunks. The tus protocol lets an upload resume after a dropped connection.
- Progress and cancellation. Users need a progress bar and a cancel button. Without them, they assume the app froze and upload the file three times.
- Provider portability. If your SDK speaks S3, moving from AWS to R2 to Backblaze is a config change. Without it, you're facing a rewrite.
- Callbacks. When an upload finishes, you need to write the URL into your database. Doing this reliably, rather than trusting the client to report back, is where most home-made upload code breaks.
That's why "S3-compatible" is the most useful phrase in this whole post. Pick an SDK that speaks S3 and you can change storage providers whenever the pricing changes. Pick one tied to a single vendor's API and your storage cost is whatever that vendor decides next year.
This connects to something I wrote about in why websites are slow. Upload widgets are among the heaviest dependencies in a frontend. Check the bundle cost before you drop a full dashboard UI into a page that only needs one button.
4. The pricing table: 11 options, two traffic levels
Numbers are easier to compare when they're applied to a real workload. Here are two scenarios built around a content site like ihatereading:
- Scenario A, "a normal month": 1.2 GB stored (about 400 posts × 3 MB of images) and 45 GB served (30,000 page views × 1.5 MB of images).
- Scenario B, "a post went viral": 12 GB stored and 1.5 TB served (1 million page views × 1.5 MB).
Unit prices come from each vendor's pricing page on 17 October 2026. The monthly totals are my own arithmetic at list rates. They leave out operations, taxes, plan seats and anything a CDN cache absorbs, so treat them as rough estimates. Section 12 shows how to get an exact number for your own repository.
| Service | Storage price | Egress price | Free tier | Scenario A | Scenario B |
|---|---|---|---|---|---|
| UploadThing | Plans: 2 GB free, 100 GB for $10 | Unlimited downloads | 2 GB, shared across apps | $0 | $10 |
| Cloudflare R2 | $0.015/GB-month | Free | 10 GB, 1M Class A ops, 10M Class B ops | $0 | ~$0.03 |
| Backblaze B2 | $6.95/TB-month | Free up to 3× your stored data, then $0.01/GB; free via partner CDNs | First 10 GB | ~$0.41 | ~$14.64, or ~$0 behind Cloudflare/bunny.net |
| Bunny Storage | $0.01/GB, one region | API egress free; CDN delivery from $0.002/GB | 14-day trial, $1/month minimum | $1 (minimum) | from ~$3 + storage |
| Vercel Blob | $0.023/GB-month (Pro) | $0.05/GB (iad1) | Hobby: 1 GB stored, 10 GB transfer, then blocked 30 days | Blocked on Hobby; ~$2.28 on Pro + seat | ~$75 + ops + seat |
| Supabase Storage | Pro: 100 GB incl., then $0.0213/GB | Pro: 250 GB incl., then $0.09/GB | 1 GB, 5 GB egress | $25 (1.2 GB passes the free 1 GB) | ~$137.50 |
| Firebase Storage | $0.026/GB past free | $0.12/GB past free | 5 GB-months, 100 GB/month (new buckets, three US regions) | $0 | ~$168 |
| AWS S3 | $0.023/GB-month | ~$0.09/GB first tier after the free allowance | 100 GB/month egress free; 12-month free tier for storage | ~$0.03 | ~$126 |
| ImageKit | Lite: 10 GB incl., then $0.10/GB | Lite: 40 GB incl., then $0.50/GB | 20 GB bandwidth, 3 GB storage | ~$11.50 | ~$739 |
| Cloudinary | 1 credit = 1 GB storage or 1 GB bandwidth or 1,000 transforms | (credit-based) | 25 credits/month | $99 (Plus: 46 credits needed, 25 free) | Well past Plus's 225 credits |
| Self-hosted (RustFS, Garage, SeaweedFS) | Your disk | Your server's bandwidth | Free software | Cost of a VPS | Cost of a VPS + bandwidth |
AWS S3's egress isn't on the pricing sheet I used for this post. Its row uses the commonly published 100 GB/month free internet egress and a first-tier rate of about $0.09/GB. Check AWS's S3 pricing page for your region before you rely on that row.
Three things jump out.
Zero-egress storage changes the shape of the bill. R2 doesn't charge for egress. UploadThing doesn't charge for downloads. B2 lets you serve free through partner CDNs. With these, a traffic spike costs about the same as a quiet month. With the others, the bill grows with traffic, and traffic is the one thing a content site can't control.
Image CDNs are priced for e-commerce, not blogs. ImageKit and Cloudinary are excellent products. Their bandwidth pricing assumes each image view is worth money to you, as it is for a store. On a free blog, the viral scenario costs more than the rest of my stack combined. If you want on-the-fly resizing, the open-source servers in section 8 give you the same feature without the per-gigabyte fee.
Supabase and Firebase are fine until the egress line. Their storage pricing is competitive. Their download pricing is what turns Scenario B into a three-figure bill. If your app already uses one of them for auth and data, keep it. Just send public, heavily-read files somewhere else.
5. Backend developers: Python and Node SDKs
If you run your own API (Django, FastAPI, Express, Hono), you want a plain S3 client. You point it at R2, B2, Tigris or your own server. Here are the libraries I'd reach for, all checked on GitHub this week.
| Library | Language | License | Last commit | Use it for |
|---|---|---|---|---|
| aws/aws-sdk-js-v3 | JS/TS | Apache-2.0 | active | The standard S3 client; works with any S3-compatible endpoint |
| boto/boto3 | Python | Apache-2.0 | active | The standard Python S3 client |
| minio/minio-py | Python | Apache-2.0 | 2026-09-29 | A smaller S3 client with a simpler API than boto3 |
| fsspec/s3fs | Python | BSD-3 | active | Treat a bucket like a filesystem in pandas, Dask and notebooks |
| jschneier/django-storages | Python | BSD-3 | 2026-08-02 | Make Django's FileField save to S3, R2, GCS or Azure |
| apache/opendal | Rust + 17 bindings | Apache-2.0 | 2026-10-17 | One API across S3, GCS, Azure, local disk and dozens more |
| unjs/unstorage | JS/TS | MIT | active | Key-value storage with many drivers (fs, Redis, S3, R2, Vercel KV) |
Presigned uploads to R2 with aws-sdk-js-v3 (Node, Hono, Express, Next.js route)
This is the pattern to learn, because almost every upload kit in section 6 is built on it. The server signs a URL. The browser uploads straight to the bucket. The server never touches the bytes.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export async function createUploadUrl(key: string, contentType: string) {
// validate here: who is the user, is the type allowed, is the key theirs?
if (!["image/png", "image/jpeg", "image/webp"].includes(contentType)) {
throw new Error("Unsupported file type");
}
const cmd = new PutObjectCommand({
Bucket: "ihatereading-assets",
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, cmd, { expiresIn: 60 }); // valid for 60 seconds
}// browser
const { url } = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, type: file.type }),
}).then((r) => r.json());
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });Swap the endpoint for Backblaze (https://s3.<region>.backblazeb2.com), Tigris, or your own RustFS box, and the rest of the code stays the same. That's the portability argument from section 3, in practice.
Python: boto3 against R2
import boto3
s3 = boto3.client(
"s3",
endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
aws_access_key_id=R2_KEY,
aws_secret_access_key=R2_SECRET,
region_name="auto",
)
# server-side upload, e.g. a cron job that renders OG images
s3.upload_file("banner.png", "ihatereading-assets", "banners/storage-sdks.png",
ExtraArgs={"ContentType": "image/png"})
# presigned PUT for a browser upload from FastAPI/Django
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "ihatereading-assets", "Key": "uploads/abc.png",
"ContentType": "image/png"},
ExpiresIn=60,
)Real-world use: a FastAPI endpoint that hands out presigned URLs, or a nightly script that renders Open Graph images and pushes them to the bucket. The same code covers both.
Python: minio-py when boto3 feels heavy
from datetime import timedelta
from minio import Minio
client = Minio("s3.us-west-002.backblazeb2.com",
access_key=KEY, secret_key=SECRET, secure=True)
client.fput_object("ihatereading-assets", "pdfs/guide.pdf", "guide.pdf",
content_type="application/pdf")
print(client.presigned_get_object("ihatereading-assets", "pdfs/guide.pdf",
expires=timedelta(hours=1)))minio-py is a separate, Apache-licensed client library. It works against any S3-compatible service and still gets regular commits, even though the MinIO server repository is no longer maintained (more on that in section 9).
Django: django-storages
If you're on Django, don't write upload code at all. Point STORAGES at S3 and every ImageField in your models saves to the bucket:
# settings.py
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": "ihatereading-assets",
"endpoint_url": f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
"access_key": R2_KEY,
"secret_key": R2_SECRET,
"custom_domain": "assets.example.com", # serve through your own domain
},
},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
}Data teams: s3fs
s3fs makes a bucket look like a folder. pandas, Dask and most of the PyData stack accept s3:// paths through it. So a notebook can read a CSV of 300 article summaries straight from R2 without a download step:
import pandas as pd
df = pd.read_parquet(
"s3://ihatereading-data/summaries.parquet",
storage_options={"client_kwargs": {"endpoint_url": R2_ENDPOINT},
"key": R2_KEY, "secret": R2_SECRET},
)One API for every storage: Apache OpenDAL
OpenDAL is the one I'd show anyone who's tired of rewriting storage code. It's an Apache Software Foundation project written in Rust. It has bindings for Python, Node.js, Java, Go, Ruby, PHP, Swift, .NET, Dart and more. You describe the backend once, and read, write, stat and list work the same way everywhere:
import opendal
op = opendal.Operator("fs", root="/tmp") # local disk in dev
op = opendal.Operator("s3", bucket="ihatereading-assets", # R2/S3 in production
region="auto", endpoint=R2_ENDPOINT,
access_key_id=R2_KEY, secret_access_key=R2_SECRET)
op.write("notes/hello.txt", b"Hello World")
print(op.read("notes/hello.txt"))Real-world use: a CLI tool or worker that runs against local disk in tests and against a bucket in production, without an if.
Key-value storage with unstorage
unstorage, from the UnJS team behind Nuxt's server engine, is a smaller idea. It gives you a single getItem/setItem API with drivers for the filesystem, memory, Redis, S3, Cloudflare KV/R2 and more. It's good for caches, cached JSON responses, and small generated files that don't need a full S3 client.
6. Frontend developers: React and Next.js upload kits
Frontend upload kits fall into two camps. Hosted kits like UploadThing and EdgeStore's hosted option bundle storage, the upload protocol and a UI. Bring-your-own-bucket kits give you the UI and the presign plumbing, and you point them at R2 or S3. For a solo builder, the choice comes down to whether you want one more vendor or one more bucket to manage.
| Library | License | Last commit | Storage | Best for |
|---|---|---|---|---|
| pingdotgg/uploadthing | MIT (client) | 2026-09-30 | Hosted | Fastest Next.js setup, unlimited downloads |
| Nic13Gamer/better-upload | MIT | 2026-05-12 | Any S3-compatible bucket | UploadThing-style DX on your own R2/S3/B2/Tigris |
| edgestorejs/edgestore | MIT | active | Hosted, S3, Azure, or custom | Type-safe routes with ready-made dropzones |
| transloadit/uppy | MIT | active | Anything (S3, tus, XHR) | Full upload dashboard: webcam, Google Drive, resumable |
| pqina/filepond | MIT | 2026-07-13 | Your endpoint | Polished drop area with image preview and crop plugins |
| react-dropzone/react-dropzone | MIT | active | None (UI only) | Headless drag-and-drop hook; bring your own upload |
| tus/tus-js-client + tus/tusd | MIT | active | tusd server → disk or S3 | Resumable uploads for large files and bad networks |
| ryanto/next-s3-upload | No license file | 2024-12-05 | S3 | Older projects only; quiet since 2024 and unlicensed |
Better Upload: the self-hosted UploadThing feel
If you like UploadThing's file-router model but want the files in your own bucket, try Better Upload. The server defines routes. The client uploads straight to the bucket with presigned URLs. Its docs list ready-made clients for AWS, Cloudflare R2, Backblaze and Tigris. Here's the official Next.js setup:
// app/api/upload/route.ts
import { route, type Router } from "@better-upload/server";
import { toRouteHandler } from "@better-upload/server/adapters/next";
import { cloudflare } from "@better-upload/server/clients";
const router: Router = {
client: cloudflare(), // or aws(), backblaze(), tigris()
bucketName: "ihatereading-assets",
routes: {
banner: route({ fileTypes: ["image/*"] }),
},
};
export const { POST } = toRouteHandler(router);"use client";
import { useUploadFile } from "@better-upload/client";
import { UploadButton } from "@/components/ui/upload-button"; // via shadcn CLI
export function BannerUploader() {
const { control } = useUploadFile({ route: "banner" });
return <UploadButton control={control} accept="image/*" />;
}The UI component installs through the shadcn CLI (npx shadcn@latest add @better-upload/upload-button), so it lands in your codebase as editable source code, not a black-box package. It also works with TanStack Start, Remix and Hono. Remember to set CORS on the bucket. Most first-time "upload failed" errors come from a missing CORS rule.
Combined with R2, this is the cheapest serious setup on this page. You get the same DX as UploadThing and zero egress, and you pay $0.015/GB once you're past R2's free 10 GB.
EdgeStore: types end to end, storage of your choice
EdgeStore defines a typed router on the server and infers the client types from it. It also ships React components for dropzones, image previews and progress bars. Its selling point is flexibility. You can start on EdgeStore's hosted storage and later switch the provider to S3-compatible storage or Azure Blob. Your components don't change. It has adapters for Next.js, TanStack Start, Remix, Astro, Hono, Express and Fastify.
Uppy: when users upload from everywhere
Uppy is the heavyweight. It gives you a dashboard modal with webcam capture, screen recording, imports from Google Drive, Dropbox and Instagram (through its Companion server), image editing, and resumable uploads over tus or S3 multipart. Use it when your users are creators uploading big media. A comment form with a profile picture doesn't need it.
import Uppy from "@uppy/core";
import Dashboard from "@uppy/dashboard";
import AwsS3 from "@uppy/aws-s3";
new Uppy({ restrictions: { maxFileSize: 500 * 1024 * 1024 } })
.use(Dashboard, { inline: true, target: "#uploader" })
.use(AwsS3, { endpoint: "/api/uppy" }); // your server signs; large files use multiparttus: resumable uploads for big files
tus is an open protocol for resumable uploads. tusd is the reference server: a Go binary that writes to local disk, S3, GCS or Azure. tus-js-client is the browser side. If your users upload 500 MB podcast episodes or screen recordings, tus means a dropped connection at 90% picks up at 90%, not at zero.
import * as tus from "tus-js-client";
const upload = new tus.Upload(file, {
endpoint: "https://uploads.example.com/files/",
chunkSize: 8 * 1024 * 1024,
retryDelays: [0, 3000, 10000],
metadata: { filename: file.name, filetype: file.type },
onProgress: (sent, total) => setProgress(Math.round((sent / total) * 100)),
onSuccess: () => console.log("stored at", upload.url),
});
upload.findPreviousUploads().then((prev) => {
if (prev.length) upload.resumeFromPreviousUpload(prev[0]);
upload.start();
});react-dropzone and FilePond: just the UI
Sometimes you already have an upload API and only need a nice drop area. react-dropzone is a headless hook: it gives you getRootProps and getInputProps, and you style everything. FilePond is the opposite: a finished, animated component with plugins for previews, crop and type validation. Either one pairs with the presigned-URL pattern from section 5.
If you're starting a new Next.js project from scratch, my roundup of the best free Next.js boilerplates and the SaaS starter kit I built cover the auth and database side. Plug one of the kits above in for files.
7. Mobile developers: Expo and React Native
Mobile uploads have problems the web doesn't. Files come from a camera or a photo library instead of an <input>. Networks drop all the time. Phones suspend apps that run in the background. And you can't ship a secret key inside an app binary, because anyone can extract it. So every mobile upload has to go through server-side signing.
| Library | License | Use it for |
|---|---|---|
| @uploadthing/expo | MIT | Image and document pickers wired to your UploadThing file router |
| RonRadtke/react-native-blob-util | MIT | Native file system access, streaming uploads and downloads, progress |
| tus/tus-js-client | MIT | Resumable uploads from React Native |
Presigned PUT with fetch | n/a | The simplest option: sign on your server, PUT the file from the app |
UploadThing in Expo
UploadThing's Expo package reuses the same file router your web app uses. It generates two hooks, useImageUploader and useDocumentUploader. They open the system picker and upload in one call. Here's the pattern from the official minimal-expo example:
// lib/uploadthing.ts
import { generateReactNativeHelpers } from "@uploadthing/expo";
import type { UploadRouter } from "~/app/api/uploadthing+api";
export const { useImageUploader, useDocumentUploader } =
generateReactNativeHelpers<UploadRouter>();// in a screen
const imageUploader = useImageUploader("videoAndImage", {
onClientUploadComplete: (files) => console.log("uploaded", files[0].key),
onUploadError: (e) => Alert.alert("Upload error", e.message),
});
<Pressable onPress={() => imageUploader.openImagePicker({ source: "library" })}>
<Text>Pick a photo</Text>
</Pressable>Real-world use: a companion app where you snap a photo, it uploads, and the web dashboard shows it, all through one router and one bill.
Bring your own bucket from React Native
If your files live in R2 or B2, use the same presigned-URL endpoint as the web app. Request a URL, then PUT the file. react-native-blob-util streams the file from disk instead of loading it into memory, which matters for video:
import ReactNativeBlobUtil from "react-native-blob-util";
const { url } = await api.post("/upload-url", { type: "video/mp4" });
await ReactNativeBlobUtil.fetch(
"PUT", url,
{ "Content-Type": "video/mp4" },
ReactNativeBlobUtil.wrap(localVideoPath), // streams from disk
).uploadProgress((sent, total) => setProgress(sent / total));For anything over about 100 MB, use tus-js-client with a tusd server instead, so an upload can survive the app going to the background.
8. Design engineers: image CDNs, resizing and placeholders
This section is for the people who care whether the banner loads crisp and fast. On a content site, design engineers make most of the decisions that drive storage costs, because images are most of the bytes.
The usual setup is: upload one large original, then serve resized, compressed versions at the edge. You can rent this from an image CDN, or run it yourself with open-source tools for the price of a small server.
| Tool | License | Last commit | What it does |
|---|---|---|---|
| lovell/sharp | Apache-2.0 | active | Fast Node.js image resizing and conversion (libvips); powers next/image in self-hosted Next.js |
| imgproxy/imgproxy | Apache-2.0 (OSS edition) | active | Self-hosted resizing server: resize/crop/format via URL, reads from S3/R2/GCS |
| cshum/imagor | Apache-2.0 | active | Thumbor-compatible resizing server in Go, also libvips-based |
| unjs/ipx | MIT | active | Image optimizer behind Nuxt Image; run with npx ipx serve |
| ascorbic/unpic-img | No license file at repo root | 2025-12-08 | One <Image> component that speaks 20+ image CDNs' URL formats |
| evanw/thumbhash | MIT | 2024-01-08 | Tiny (~25 byte) blurred placeholder that keeps aspect ratio and alpha |
| woltapp/blurhash | MIT | 2024-05-21 | The older, widely supported compact placeholder format |
| cloudinary-community/next-cloudinary | MIT | 2026-10-01 | Next.js components for Cloudinary, if you're already on it |
Compress before you upload (the cheapest optimization there is)
Every gigabyte you don't store is a gigabyte you never pay to store or serve. A sharp script in your publish step does more for your bill than any pricing tier:
import sharp from "sharp";
await sharp("banner.png")
.resize({ width: 1600, withoutEnlargement: true })
.webp({ quality: 80 })
.toFile("banner.webp");A 2x PNG banner at 3200×1800 often comes out several times smaller as WebP at a width of 1600, with no visible difference in a blog layout. Measure your own images and keep the setting that looks right to you.
Self-hosted resizing with imgproxy
imgproxy runs as a Docker container and reads originals from S3-compatible storage. It returns whatever size and format the URL asks for. Put your CDN in front of it, and each variant is only generated once:
https://img.example.com/insecure/rs:fill:800:450/f:webp/plain/s3://ihatereading-assets/banners/storage-sdks.pngIn production, turn on URL signing so strangers can't make your server generate 10,000 sizes. The insecure segment in the example above only works when signing is disabled. imagor does the same job with Thumbor-style URLs. ipx is the lighter option if you're already in the Nuxt/Nitro world.
Placeholders: thumbhash and blurhash
A placeholder is a tiny blurred preview shown while the real image loads. It stops layouts from jumping and makes slow connections feel faster. Compute it once at upload time, store the string next to the image URL in your database, and decode it on the client. It costs nothing at read time:
import sharp from "sharp";
import { rgbaToThumbHash, thumbHashToDataURL } from "thumbhash";
// at upload time (server)
const { data, info } = await sharp(input)
.resize(100, 100, { fit: "inside" })
.ensureAlpha().raw()
.toBuffer({ resolveWithObject: true });
const hash = rgbaToThumbHash(info.width, info.height, data);
const placeholder = Buffer.from(hash).toString("base64"); // save this in your database
// at render time (client)
const dataUrl = thumbHashToDataURL(Uint8Array.from(atob(placeholder), (c) => c.charCodeAt(0)));ThumbHash holds more detail than BlurHash in a similar number of bytes, and it keeps the aspect ratio and transparency. BlurHash has more ports and existing integrations. Both repositories are quiet because they're finished, not because they're abandoned.
When an image CDN is worth paying for
ImageKit and Cloudinary earn their price when images make you money: product photos, a marketplace, a SaaS where customers upload and transform media. You get face-aware cropping, background removal, video transcoding, a DAM and support. For a free publication, the per-gigabyte bandwidth model is the same trap as section 1, just with better features. Run the scenario numbers from section 4 before you pick one.
If you produce visuals at volume, like I do for the Instagram carousels made with Nano Banana and OpenRouter, do the compression and placeholder generation in that pipeline, before anything touches storage.
9. Self-hosted S3 servers (and the MinIO problem)
If you already pay for a VPS, or your files can't leave your own servers, you can run an S3-compatible server and use every SDK above against it. Until recently the answer was always MinIO. That has changed.
| Server | License | Last commit | Notes |
|---|---|---|---|
| minio/minio | AGPL-3.0 | 2026-02-12 | README now says "THIS REPOSITORY IS NO LONGER MAINTAINED" and points to its commercial AIStor editions |
| rustfs/rustfs | Apache-2.0 | 2026-10-16 | Rust, S3-compatible; pitches itself as MinIO's simplicity under a permissive license |
| deuxfleurs-org/garage | AGPL-3.0 | 2026-10-16 | Lightweight, built for small-to-medium self-hosting across several sites and cheap hardware |
| seaweedfs/seaweedfs | Apache-2.0 | 2026-10-15 | Distributed blob store built for billions of small files, with an S3 gateway, filer and FUSE mount |
| versity/versitygw | Apache-2.0 | 2026-10-16 | A gateway that exposes an existing POSIX filesystem as an S3 API |
What I'd do today:
- One small VPS, a few hundred GB of images: Garage or RustFS. Both run as a single binary and are easy to start. Garage has the longer track record. RustFS uses the more permissive license, which matters if you'll ship it inside a product.
- Lots of small files, growing fast: SeaweedFS. It was designed around the many-small-files problem that hurts other stores.
- You already have a NAS or a big disk: VersityGW. It lets S3 tools read and write the files you already have without moving them.
- You run MinIO today: keep it running, but plan a move. An unmaintained storage server is a security risk you'll eventually have to deal with.
Self-hosting isn't free. You pay for the server, the bandwidth, backups and your own time on call. It makes sense when you already run servers, or when the egress you'd pay a cloud provider is more than a VPS costs. For most solo builders, R2 or UploadThing is cheaper once you count your own hours.
10. Backends that ship file storage built in
Two open-source backends include file storage alongside auth and a database. If you're starting a project from scratch and want one system instead of three, they're worth a look:
- PocketBase (MIT): a single Go binary with SQLite, auth, realtime and file fields. Files go to local disk by default, or to any S3-compatible bucket with one setting. It's great for internal tools and side projects, and you can run it on a $5 VPS.
- Appwrite (BSD-3-Clause): a fuller backend-as-a-service with storage buckets, image previews, permissions, functions and SDKs for web, Flutter, Android and Apple platforms. You can self-host it or use their cloud.
Either one gives you a Firebase-like developer experience while you keep the storage bill. Point PocketBase at R2 and you have auth, a database and zero-egress files for the price of a tiny server.
11. How to pick in five minutes
Here are the decision rules I actually use, ordered from the simplest setup to the most control:
- You're shipping a React/Next.js app this week and want zero thinking: UploadThing. Free up to 2 GB, unlimited downloads, typed routes, and an Expo package if a mobile app follows.
- You want your own bucket but the same DX: Better Upload + Cloudflare R2. Zero egress, $0.015/GB past 10 GB, and the UI lives in your codebase.
- You're on Django/FastAPI: boto3 or django-storages pointed at R2 or B2. Use presigned URLs so uploads skip your server.
- You need one codebase across many storage backends: OpenDAL.
- Users upload big media from flaky networks: Uppy on the web, tus on mobile, tusd writing to S3.
- You want image resizing: compress at upload with sharp. Resize at the edge with imgproxy or ipx behind your CDN. Store a thumbhash.
- You must self-host: Garage or RustFS on a VPS. Don't start new projects on MinIO.
- You already use Supabase or Firebase: keep them for user-owned private files. Put public, heavily-read assets on R2 or UploadThing.
The common thread: separate the files people download a lot from the files people pay you for. The first group needs zero or flat egress. The second can live wherever is convenient.
I followed the same thinking in my open-source projects and in the four repos I wrote about in Tubelog, Cizle, OpenNote and CRM SaaS. Each one ships with a storage choice someone can afford to self-host. Tubelog, for example, turns a YouTube channel into markdown files, and those files compress to almost nothing.
12. Estimate the bill before it arrives: StackBudget
Every number in section 4 is napkin math. Your real cost depends on what your repository actually uses: which storage SDK, which database, which AI API, which hosting platform. It also depends on traffic you haven't seen yet.
That's why I built StackBudget, an open-source Claude Code skill that reads a codebase and returns an interactive calculator of its monthly running cost.
How it works:
- Scan. Point it at the current folder, a local path, or any GitHub repository. It reads manifests (
package.json,requirements*.txt,pyproject.toml,go.mod), deploy configs, Dockerfiles and the key names in.env.example-style files. It never opens real.envfiles and never changes your code. Its detection rules already cover the storage options in this post, includinguploadthing,@vercel/blob, R2, S3, Supabase and Firebase. - Price. It applies a dated pricing table, including R2's and S3's per-GB and per-operation rates.
- Project. It shows the bill at launch, growth and scale traffic levels, so you can see what happens when a post goes viral.
- Migrate. Every service has a Migrate to picker. Switch Vercel Blob to R2, or Vercel hosting to Cloudflare, and the total updates on the spot.
- Share. The report is a single HTML file you can send to a client, a CTO or a co-founder before anyone signs up for a paid plan.
The README's example is reloop-labs/reloop, an open-source Resend alternative. At 5,000 users and 1 million emails a month, its managed stack comes to about $359/month. "Pick cheapest stack" brings it down to about $96/month.
Install it once, then run it on any repository:
git clone --depth 1 https://github.com/shreyvijayvargiya/stackbudget-calculator /tmp/stackbudget \
&& mkdir -p ~/.claude/skills \
&& cp -r /tmp/stackbudget/skills/stack-budget ~/.claude/skills/ \
&& rm -rf /tmp/stackbudget/stack-budget # the current folder
/stack-budget owner/repo # any GitHub repositoryOr just ask Claude Code: "what would it cost to run this repo at 50k users?" Run it before you pick a storage provider, then flip the storage row to R2 or UploadThing and compare. The difference between the two totals is exactly the amount this post has been about.
If it misses a service your project uses, open an issue or a pull request. The pricing table is plain data, so adding a new provider takes a few lines.
Wrap-up
My rule for ihatereading is simple: text in the database, files in storage that doesn't charge per download, and a cost estimate before every new dependency. UploadThing covers my React apps today. If I outgrow it, Better Upload on R2 is the move, and nothing above the storage layer has to change.
If this was useful, the monthly ihatereading magazine collects posts like this one, and the full archive is on the blog.