# What's the Best Cloud Storage API? Upstash Blob vs. Vercel Blob & others (2026 Comparison)

> **Source:** https://upstash.com/blog/whats-the-best-cloud-storage-api-for-a-side-project
> **Date:** 2026-10-05
> **Author(s):** Josh
> **Reading time:** 13 min read
> **Tags:** blob
> **Format:** text/markdown — machine-readable content for agents and LLMs

Upstash Blob vs. Vercel Blob vs. UploadThing comparison in pricing and setup for 2026.

---

For a side project, you need a cloud storage API that offers a great free tier and can be set up within a few minutes. For most Next.js projects as well as serverless projects, Upstash Blob is the best choice. It only needs a single API token and a few lines of code. The free plan simply pauses when the free limit is reached.

In this post I'll compare the pricing of 8 different storage services and look at a code example of storing files in an Upstash Blob bucket.

## Which cloud storage API should a side project use?

Which one you pick depends a lot on your stack and what your project stores:

- **Your app runs on a serverless platform and you want file storage in 1-2 minutes:** [Upstash Blob](https://upstash.com/blob). The free plan includes 1 GB of storage and 10 GB of egress a month, and on pay-as-you-go the first 1 TB of egress each month is free.
- **You want the biggest free allowance and are fine with an S3-style setup:** [Cloudflare R2](https://developers.cloudflare.com/r2/pricing/). You get 10 GB of storage free, and egress is free too.
- **Your project already runs on AWS:** S3. Your IAM (Identity and Access Management, AWS's permission system) roles and Lambda functions are already there, and it's all on the same bill.
- **You already use Supabase for the database and auth:** Supabase Storage, so your files live in the same project as your database and users.
- **Your app mostly resizes and crops images or video:** Cloudinary, because the transforms are the product.

Here's how Upstash Blob's plans compare:

![](https://cdn.bydefault.so/image-nHbwVUMxJUdLpkUgIgFX1.png)

## What matters when you pick storage for a side project?

For a side project, the free tier and the setup decide almost everything, because storage at hobby scale costs cents. A 1 GB bucket costs [$0.02 a month on Upstash Blob](https://upstash.com/pricing/blob) and [$0.015 on R2](https://developers.cloudflare.com/r2/pricing/). That half cent won't decide anything.

Six things change the bill or the setup time:

- **What happens when you pass the free tier.** Upstash Blob's free plan is a hard limit. Past it, the bucket stops serving requests until the 30-day window rolls over, and [the free plan is never charged](https://upstash.com/pricing/blob).
- **Egress.** This is how many bytes you move out of the bucket. So every time someone downloads a file, this counts. S3 charges [$0.09 per GB](https://aws.amazon.com/s3/pricing/) after the first 100 GB a month. R2 egress is free, and Upstash Blob egress is free up to 1 TB a month on pay as you go.
- **Operation fees.** Each upload, read and list is a billed request. Upstash Blob splits them into cheap reads and pricier writes and lists, and deletes are free.
- **How many secrets you manage.** One bucket token is one environment variable. S3 means an access key, a secret key, a region and an IAM policy.
- **Serverless fit.** The SDK should run on Vercel Functions, Cloudflare Workers and other short-lived runtimes.
- **Browser uploads.** [Vercel Functions reject request bodies over 4.5 MB](https://upstash.com/blog/whats-the-best-object-storage-for-a-saas-september-2026-comparison), so larger files have to go from the browser straight to storage. That needs signed upload links or an upload handler.

## How do the storage APIs compare on price and free tier?

R2 has the biggest free tier, and its egress is free. Upstash Blob gives you 1 TB of free egress a month on pay as you go. S3 is the most expensive for serving files at $0.09 per GB, and Supabase charges the same for egress that doesn't hit its cache. 

I took the prices below from each provider's pricing page (as of October 2026)

| Service | Free tier | Storage per GB-month | Egress | Requests |
| --- | --- | --- | --- | --- |
| [Upstash Blob](https://upstash.com/pricing/blob) | 1 GB, 10 GB egress, 10,000 reads, 2,000 writes and lists a month | $0.02 | Free up to 1 TB a month, then `$`0.02/GB | $0.30 per 1M reads, $4.50 per 1M uploads, copies and lists. Deletes free |
| [Cloudflare R2](https://developers.cloudflare.com/r2/pricing/) | 10 GB, 1M writes and lists, 10M reads a month (Standard storage only). Needs an R2 subscription | $0.015 | Free | $4.50 per 1M writes and lists, $0.36 per 1M reads. Deletes free |
| [AWS S3](https://aws.amazon.com/s3/pricing/) | New accounts: $100 in credits, plus up to $100 more from activities | $0.023 | First 100 GB a month free (shared across AWS), then $0.09/GB | $5 per 1M writes and lists, $0.40 per 1M reads. Deletes free |
| [Vercel Blob](https://vercel.com/docs/vercel-blob/usage-and-pricing) | Hobby: 1 GB, 10 GB transfer, 10,000 reads, 2,000 writes and lists. Hard limit | $0.023 | Hobby: 10 GB. Pro: Flat Rate CDN, 1 TB a month shared with your other CDN traffic. On-demand: $0.05/GB | $0.40 per 1M reads (cache misses only), $5 per 1M writes and lists. Deletes free |
| [Supabase Storage](https://supabase.com/pricing) | 1 GB, 5 GB egress, 5 GB cached egress, 50 MB max file. Pauses after 1 week idle | Pro ($25/month): 100 GB, then $0.0213 | Pro: 250 GB, then $0.09/GB. Cached: 250 GB, then $0.03/GB | No per-request fee listed |
| [UploadThing](https://uploadthing.com/pricing) | 2 GB shared across apps | $10/month for 100 GB, or $25/month for 250 GB, then $0.08/GB | No price listed. Free plan lists unlimited downloads | No per-request fee listed |
| [Bunny Storage](https://bunny.net/pricing/storage/) | 14-day trial, then $1 monthly minimum | From $0.01 (one region) | Free to Bunny CDN. CDN from $0.01/GB in Europe and North America | No API fees |
| [Cloudinary](https://cloudinary.com/pricing) | 25 credits a month | Credits: 1 credit = 1 GB stored, 1 GB bandwidth or 1,000 transforms | Credits | Credits |

If you serve 1,000 GB in a month, egress costs [$0 on Upstash Blob and $81 on S3](https://upstash.com/blog/upstash-blob-now-includes-1-tb-of-free-egress-every-month) (900 billable GB × $0.09). It's also $0 on R2. On all three, you pay for storage and requests on top of that.

Here is the egress bill for that month:

![](https://cdn.bydefault.so/image-zom8oaiG6JoSk0tZUdnIj.png)

For a side project, Upstash Blob and R2 only differ by a few cents a month. Where they really differ is setup, which is much easier on Upstash Blob.

## How hard is setup: one token or IAM keys?

On Upstash Blob, you set up one token in one environment variable. On S3, you need an IAM user, a policy, two keys and a region.

Here's how to get an Upstash Blob token:

1. Create a bucket in the [Upstash Console](https://console.upstash.com/blob) and choose public or private.
2. Copy the bucket token.
3. Put it in your `.env` file as `UPSTASH_BLOB_TOKEN`.

If you prefer the terminal, the [Upstash CLI](https://upstash.com/docs/agent-resources/cli) does the same thing:

```bash
upstash blob create --name side-project --visibility public
upstash blob credentials --bucket-id $BUCKET_ID
```

Then your `.env` file looks like this:

```bash
UPSTASH_BLOB_TOKEN=your_bucket_token
```

Here's the client setup and an upload:

```ts
import { Bucket } from '@upstash/blob'

const bucket = Bucket.fromEnv()

await bucket.put('notes.json', JSON.stringify({ notes: ['Hello'] }), {
  contentType: 'application/json'
})
```

The client reads `UPSTASH_BLOB_TOKEN` automatically. Cloudflare Workers have no `process.env`, so there you [pass the token in directly](https://upstash.com/docs/blob/bucket/connecting) with `new Bucket({ token: env.UPSTASH_BLOB_TOKEN })`. The token can read and write the whole bucket, so it should only live in server-side variables (not in a `NEXT_PUBLIC_` one).

S3 needs a lot more setup before your first upload. First you need an IAM user with a policy like this one, plus an access key pair for that user:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*"
    }
  ]
}
```

Then the client needs a region, both keys and the bucket name:

```ts
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'

const client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
  }
})

await client.send(new PutObjectCommand({
  Bucket: process.env.S3_BUCKET!,
  Key: 'notes.json',
  Body: JSON.stringify({ notes: ['Hello'] }),
  ContentType: 'application/json'
}))
```

So that's four environment variables and a policy file, versus one token. Upstash Blob is also S3-compatible. Calling `bucket.s3()` [returns temporary S3 credentials](https://upstash.com/docs/blob/bucket/connecting), so you can still use tools built for the S3 API later.

## Example: back up user data as JSON with Upstash Blob

For example, let's say your side project is a notes app. Every night, it saves a JSON copy of each user's notes, keeps the newest three copies, and lets the user download the latest one. We can build that in under 60 lines with four calls: `put`, `list`, `get` and `del`.

Here's how it works:

![](https://cdn.bydefault.so/drawing-ym_H0Ow8SiTHRvMJ7jKwY.png)

This script saves five backups, deletes all but the newest three, restores the newest one, creates a download link that expires after 5 minutes, and then cleans up:

```ts
import { Bucket } from '@upstash/blob'
import { setTimeout as delay } from 'node:timers/promises'

const bucket = Bucket.fromEnv()

async function backup(userId: string, data: unknown) {
  const timestamp = new Date().toISOString().replaceAll(':', '-')
  return bucket.put(`backups/${userId}/${timestamp}.json`, JSON.stringify(data), { contentType: 'application/json' })
}

async function prune(userId: string, keep = 3) {
  const { blobs } = await bucket.list({ prefix: `backups/${userId}/` })
  const paths: string[] = []
  for (const blob of blobs) paths.push(blob.path)
  paths.sort()
  const oldPaths = paths.slice(0, Math.max(0, paths.length - keep))
  if (oldPaths.length) await bucket.del(oldPaths)
}

async function restoreLatest(userId: string) {
  const { blobs } = await bucket.list({ prefix: `backups/${userId}/` })
  const paths: string[] = []
  for (const blob of blobs) paths.push(blob.path)
  const latest = paths.sort().at(-1)
  if (!latest) throw new Error('No backups found')
  const blob = await bucket.get(latest)
  return JSON.parse(await new Response(blob.body).text())
}

function downloadLink(path: string) {
  return bucket.signedReadUrl(path, { expiresIn: '5m' })
}

async function main() {
  let latestPath = ''
  try {
    for (let revision = 1; revision <= 5; revision++) {
      const result = await backup('u_42', { notes: [`Note ${revision}`], revision })
      latestPath = result.path
      if (revision === 1) console.log('Bucket:', result.url ? 'public' : 'private')
      if (revision < 5) await delay(1100)
    }
    await prune('u_42')
    const { blobs } = await bucket.list({ prefix: 'backups/u_42/' })
    for (const blob of blobs) console.log(blob.path, blob.size)
    console.log('Restored:', await restoreLatest('u_42'))
    const { url } = await downloadLink(latestPath)
    const link = new URL(url)
    console.log('Signed URL:', link.origin + link.pathname)
    const response = await fetch(url)
    console.log('HTTP:', response.status, (await response.text()).slice(0, 60))
  } finally {
    await bucket.del({ prefix: 'backups/u_42/' })
    console.log('Exists after cleanup:', await bucket.exists(latestPath))
  }
}

await main()
```

If we run it with `npx tsx backup.ts`, we get this (shortened, full output is longer):

```text
Bucket: public
backups/u_42/2026-10-05T08-18-53.663Z.json 33
backups/u_42/2026-10-05T08-18-54.953Z.json 33
backups/u_42/2026-10-05T08-18-56.261Z.json 33
Restored: { notes: [ 'Note 5' ], revision: 5 }
Signed URL: https://<account-id>.r2.cloudflarestorage.com/<bucket-id>/backups/u_42/2026-10-05T08-18-56.261Z.json
HTTP: 200 {"notes":["Note 5"],"revision":5}
Exists after cleanup: false
```

The two oldest backups are gone, and the signed link returned the newest file with a 200.

- **ISO timestamps sort by date.** If you sort the paths as plain strings, the newest backup ends up last, so the script finds it with a single list call.
- **One `del` call removes many files.** It accepts a single path, an array, or a whole prefix, and [deleting a missing file counts as success](https://upstash.com/docs/blob/bucket/deleting). Deletes are also [free](https://upstash.com/pricing/blob).
- **A prefix is the only filter.** You [can't query by owner, type or date](https://upstash.com/docs/blob/bucket/reading), so we use the folder layout `backups/<userId>/` to group files by user. One list call returns up to 1,000 files, so for longer histories you need the cursor it returns.

On the free plan, this script uses 8 writes and lists (5 uploads, 3 lists) out of 2,000 a month, and just a few reads out of 10,000. 

If a background job builds CSV or PDF exports for you, the [exports guide](https://upstash.com/docs/blob/recipes/exports) shows how to do it with a private bucket and the same kind of signed link.

## How do browser uploads and file links work?

With Upstash Blob, the browser uploads files directly to the bucket, and your server just approves each upload. This way big files go straight from the browser to storage, which you need because [Vercel Functions reject request bodies over 4.5 MB](https://upstash.com/blog/whats-the-best-object-storage-for-a-saas-september-2026-comparison).

Here's how server code and browser uploads both reach the same bucket:

![Browser uploads with Upstash Blob](https://cdn.bydefault.so/drawing-C0z9r7v3V-9LiqQM3Rb9j.png)

The browser can upload in two ways:

- **The upload handler and React hook.** Your server route runs the [upload handler](https://upstash.com/docs/blob/uploads/upload-handler), which checks the file size and type and picks the path. The browser calls `useUpload()`. Files over 16 MB upload in parts, and you can pause, resume or retry them.
- **A signed upload link.** Your server creates a link with `bucket.signedUploadUrl()` that lasts [up to 10 minutes](https://upstash.com/docs/blob/bucket/writing), and the browser sends the file to it.

How you serve files depends on whether you created a public or private bucket:

| Bucket | How readers get a file | Good for |
| --- | --- | --- |
| Public | A permanent URL like `https://<id>.blob.upstash.io/path`, served by Cloudflare's CDN at no extra cost | Avatars, attachments in public posts, static assets |
| Private | A [signed read link](https://upstash.com/docs/blob/bucket/reading) that lasts 5 minutes by default and 10 minutes at most | Backups, invoices, exports, anything personal |

I ran the backup script on a public bucket, so the signed link was optional there. For real user backups, you'd use a private bucket, where [every read goes through a signed link](https://upstash.com/docs/blob/overall/quickstart).

A single file can be up to [5 TB](https://upstash.com/pricing/blob), which is plenty for a side project. Files over 100 MB work best as multipart uploads, where each part counts as one write.

## What's the best storage API for a side project?

For most projects Upstash Blob is the best pick. You get one token, a few lines of code, a generous free plan, and 1 TB of free egress a month once you grow into pay as you go.

You also get the R2 storage layer without the R2 setup. Upstash Blob buckets [run on R2 storage](https://upstash.com/docs/blob/bucket/connecting), and on top of that, you get the single token setup, the upload handler for browser uploads, and a built-in CDN for public files.

A different service can make sense when your project already depends on it, or needs one specific feature:

- **Your whole app already runs on AWS.** S3 fits into the Lambda functions, CloudFront and IAM roles you already have.
- **Supabase already holds your database and users.** Supabase Storage keeps everything on one dashboard and one bill. Its free plan limits uploads to [50 MB per file](https://supabase.com/pricing).
- **Your app is mostly image and video edits.** Cloudinary charges for resizing, cropping and format changes, and its [credit plan](https://cloudinary.com/pricing) counts every 1,000 transforms as one credit.

For everything else, including most Next.js and serverless side projects, Upstash Blob is the fastest way to go from an empty repo to working storage.

---

This site has a search endpoint: https://context7.com/api/v2/ask?siteKey=ask_4cf2adc7846aa874f833b068&query=<URL-encoded question>. It returns documentation that answers the question, with a source link for each part. No API key is needed. If nothing matches, it says so.