# Building A Multi-File Drag-and-Drop Uploader With Upstash Blob

> **Source:** https://upstash.com/blog/react-file-upload-a-multi-file-drag-and-drop-uploader-with-upstash-blob
> **Date:** 2026-10-07
> **Author(s):** Josh
> **Reading time:** 13 min read
> **Tags:** blob
> **Format:** text/markdown — machine-readable content for agents and LLMs

---

In this article I wanna show you a clean and simple way to implement file uploads in a plain React SPA (Single Page Application) with no Next.js. We will build a photo-drop page for an event album with Vite, a small Hono server and [Upstash Blob](https://upstash.com/docs/blob/overall/quickstart). 

We'll keep all sensitive tokens on the server, while each photo will be uploaded directly from the browser to the bucket. Each file will also get its own progress bar, cancel button and retry option. This is the finished page when a guest drops three photos and one text file:

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

## What should you use for file uploads in a React app?

Most React apps work well with a headless dropzone like react-dropzone for the UI and object storage that accepts uploads straight from the browser. A small server route sits between them and approves each upload, so the storage key never reaches the client.

Choosing a UI library to handle uploads is fairly straightforward. The library gives us `File` objects, and we need somewhere to store them.

Here is how the main options split by what they do for you:

- **A UI library only.** react-dropzone gives you drag and drop and file checks. FilePond and Uppy give you a full widget, and Uppy supports [resumable uploads over tus](https://uploadcare.com/blog/best-react-file-upload-libraries/). You still bring storage and a server.
- **A managed upload service.** UploadThing ships ready-made `UploadButton` and `UploadDropzone` components, and [checks auth on your server while the upload goes to theirs](https://uploadthing.com/pricing). It's great when you want a drop-in widget and a flat monthly bill.
- **Object storage with direct uploads.** [Upstash Blob](https://upstash.com/blob), S3 presigned URLs and Cloudflare R2 store the bytes. You pick the UI, and your route signs each upload.
- **A media platform.** Cloudinary stores files and also resizes and transforms them. It's great when you need image or video edits on the fly.

We'll use Upstash Blob! On Next.js, our [Next.js file storage guide](https://upstash.com/blog/best-file-storage-for-nextjs-a-practical-guide-to-nextjs-file-uploads) covers the same idea with route handlers. If you mainly want prices side by side, our [React upload cost breakdown](https://upstash.com/blog/file-uploads-in-a-react-app-what-to-use-and-pricing-breakdown-2026) covers that in detail.

## Why can't a React SPA keep the storage token in the browser?

The Upstash Blob token is a bearer secret that can [read and write the whole bucket](https://upstash.com/docs/blob/bucket/connecting). So, it has to stay on a server. Vite copies every `VITE_` variable into the JavaScript bundle, so anyone can read it in their browser's dev tools.

A plain React SPA has no server of its own, so we add a small one. Its only job in the upload flow is to say yes or no to the upload. The file itself never passes through it.

Here is what happens when a guest drops a photo:

1. The browser asks our Hono route to begin an upload, sending along the user’s session header.
2. The route checks who the user is, picks a storage path, and signs a URL for that one file.
3. The browser sends the file straight to Upstash Blob with that URL.
4. The browser tells the route the upload finished, and the route saves a record to the database.

A signed URL is [only good for one object, one method, and for a few minutes](https://upstash.com/blog/whats-the-best-object-storage-for-a-saas-september-2026-comparison). If a signed URL is leaked, it can't open the rest of the bucket. Because the bytes skip our server, uploads don’t hit [the 4.5MB request body limit on Vercel or the 6MB limit on AWS Lambda](https://upstash.com/docs/blob/uploads/upload-handler).

The full round trip looks like this:

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

## How do you set up the upload route with Hono?

All of the upload functionality is provided by a single upload handler from `@upstash/blob` which we’ll mount onto two Hono routes. It handles user authentication, enforces file constraints, signs each upload request, and runs a callback once the file is in the bucket.

Install the server and client packages:

```bash
npm install @upstash/blob @upstash/redis hono @hono/node-server react-dropzone
```

The secrets for the server go in its environment, without a `VITE_` prefix: `UPSTASH_BLOB_TOKEN` from the [Upstash console](https://console.upstash.com), plus `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` for the album records. We store those records in [Upstash Redis](https://upstash.com/docs/redis/overall/getstarted), one hash per album.

This is the whole handler, in `server/uploads.ts`:

```typescript
import { BlobError, uploadHandler } from "@upstash/blob"
import { Redis } from "@upstash/redis"

export const redis = Redis.fromEnv()

export function getUser(request: Request) {
  const token = process.env.DEMO_SESSION_TOKEN
  if (!token || request.headers.get("authorization") !== `Bearer ${token}`) {
    throw new BlobError("unauthorized")
  }
  return { id: "demo-user" }
}

export const uploads = uploadHandler({
  constraints: { maxSize: "25mb", contentTypes: ["image/*"] },
  onBeforeUpload: async ({ request, file }) => {
    const user = getUser(request)
    return {
      path: `albums/${user.id}/${crypto.randomUUID()}-${file.name}`,
      metadata: { owner: user.id },
    }
  },
  onUploadComplete: async ({ uploadId, metadata, file, path, url, size, contentType }) => {
    const photo = { uploadId, name: file.name, path, url, size, contentType }
    await redis.hset(`photos:${metadata.owner}`, { [uploadId]: photo })
  },
})
```

Each part of the handler does one job:

- **`getUser`** is a mocked auth check
- **`constraints`** limits each photo to 25 MB and to image types. The server enforces both, and the client reads them to reject big files before any request
- **`onBeforeUpload`** picks the storage path. A random UUID in front of the file name means two guests uploading `IMG_0001.jpg` don't overwrite each other
- **`onUploadComplete`** saves the photo record. It writes to a hash field keyed by `uploadId`, so a second call overwrites the same field instead of adding a duplicate

If the Redis write fails, `onUploadComplete` throws and the SDK [deletes the uploaded object](https://upstash.com/docs/blob/uploads/upload-handler). The bucket never keeps a photo the album doesn't know about.

The Hono app mounts the handler and adds one route that lists the album, in `server/index.ts`:

```typescript
import { serve } from "@hono/node-server"
import { Hono } from "hono"
import type { Context } from "hono"
import { BlobError } from "@upstash/blob"
import { getUser, redis, uploads } from "./uploads"

async function listPhotos(context: Context) {
  const user = getUser(context.req.raw)
  const photos = await redis.hgetall(`photos:${user.id}`)
  return context.json(Object.values(photos ?? {}))
}

function handleError(error: Error, context: Context) {
  if (BlobError.is(error)) return Response.json(error.toJSON(), { status: error.status })
  return context.json({ error: "Album request failed" }, 500)
}

const app = new Hono()
app.get("/api/upload", (context) => uploads.GET(context.req.raw))
app.post("/api/upload", (context) => uploads.POST(context.req.raw))
app.get("/api/photos", listPhotos)
app.onError(handleError)
serve({ fetch: app.fetch, port: 3001 })
```

The handler's `GET` and `POST` take a standard fetch `Request`, so `c.req.raw` is all Hono needs to pass. The `GET` route serves the size and type rules to the browser. The `POST` route handles the begin and finish steps from the flow above.

In development, the Vite dev server forwards `/api` to Hono, so the browser calls the same origin and our route needs no CORS setup:

```typescript
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"

export default defineConfig({
  plugins: [react()],
  server: { proxy: { "/api": "http://localhost:3001" } },
})
```

## How do you build a drag-and-drop uploader with progress and cancel?

On the React side, react-dropzone collects the files and passes the whole array to the `useUpload` hook from `@upstash/blob/react`. The hook returns one record per file with its status, percent, and `cancel` and `retry` functions, so the UI is a list that renders those records.

First, bind the hooks to the server handler's type and attach the session header, in `src/upload.ts`:

```typescript
import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "../server/uploads"

async function getHeaders() {
  return { authorization: `Bearer ${sessionStorage.getItem("session-token") ?? ""}` }
}

export const { useUpload } = uploadHooks<typeof uploads>({ headers: getHeaders })
```

The `import type` line is deleted automatically at build time, so no server code or token ends up in the browser bundle. Your login flow puts the session token in `sessionStorage`. The SDK calls `getHeaders` before every request to our route, so a refreshed token gets picked up mid-upload.

Then the component, in `src/PhotoDrop.tsx`:

```tsx
import { formatBytes } from "@upstash/blob/react"
import type { UploadRecord } from "@upstash/blob/react"
import { useDropzone } from "react-dropzone"
import { useUpload } from "./upload"

function PhotoRow({ upload }: { upload: UploadRecord }) {
  return (
    <li>
      <div className="file-heading">
        <strong>{upload.file.name}</strong>
        <span>{formatBytes(upload.file.size)}</span>
      </div>
      <span className="status">{upload.status === "finishing" ? "Finishing…" : upload.status}</span>
      {upload.pending && <progress max={100} value={upload.percent} aria-label={`${upload.file.name} progress`} />}
      {upload.pending && <button onClick={upload.cancel}>Cancel</button>}
      {upload.status === "error" && <button onClick={upload.retry}>Retry</button>}
      {upload.error && <p role="alert">{upload.error.message}</p>}
      {upload.blob?.url && <a href={upload.blob.url} target="_blank" rel="noreferrer">
        <img src={upload.blob.url} alt={upload.file.name} />
      </a>}
    </li>
  )
}

export function PhotoDrop() {
  const { start, uploads, constraints } = useUpload()
  const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
    onDrop: (files) => start({ files }),
    accept: { "image/*": [] },
    maxSize: constraints?.maxSize,
  })

  return (
    <main>
      <h1>Photo drop</h1>
      <div {...getRootProps({ className: "dropzone" })}>
        <input {...getInputProps()} />
        <strong>{isDragActive ? "Drop your photos here" : "Drop photos here, or click to choose"}</strong>
        <p>Images up to {formatBytes(constraints?.maxSize ?? 25_000_000)} each · Select several at once</p>
      </div>
      {fileRejections.map(({ file, errors }) => <p role="alert" key={file.name}>
        {file.name}: {errors.map((error) => error.message).join(", ")}
      </p>)}
      <ul>{uploads.map((upload) => <PhotoRow key={upload.id} upload={upload} />)}</ul>
    </main>
  )
}
```

A few details in this component:

- **`start({ files })`** takes the whole drop at once. The hook [uploads three files at a time by default and queues the rest](https://upstash.com/docs/blob/uploads/upload-client), so a guest who drops 200 photos doesn't open 200 connections.
- **`constraints`** comes from the server's `GET` route. Passing its `maxSize` to react-dropzone means the 25 MB rule lives in one place, the handler.
- **`accept`** lets react-dropzone reject non-images in the browser. The server still checks the type, because [the size check is the only one the SDK runs in the browser](https://upstash.com/docs/blob/uploads/constraints).
- **`Finishing…`** covers the gap after the last byte. The percent [stays at 99 until the upload is done](https://upstash.com/docs/blob/uploads/large-files), while the route runs `onUploadComplete`.

When we test this UI with a real bucket, dropping two small PNGs, one 19.6 MB JPEG and a text file at once, we can see the text file was rejected right away, and the small files moved to finishing while the JPEG was still uploading:

![](https://b27353de2921.blob.upstash.io/article-assets/react-file-upload/ui-progress.png)

All three photos ended as done, and the album route returned three records. When we replayed the finish request, the count stayed at three, because the callback writes to the same `uploadId` field. Pressing Cancel on the JPEG in a second run moved it to canceled and told the route to abort the upload.

## What happens with big files, flaky networks and abandoned uploads?

The SDK handles splitting big files into parts, retrying failed requests, and resuming uploads after a page reload. You still need to make sure database writes can be safely repeated, clean up uploads that never finished, and treat every stored file as untrusted.

The following are handled by the client automatically:

- **Big files.** Files over [16 MB are uploaded in parts](https://upstash.com/docs/blob/uploads/large-files). Up to four parts of a single file can be uploaded concurrently, up to a total of six concurrent requests per page. The 19.6 MB JPEG used for testing was uploaded using this multipart upload mechanism.
- **Flaky networks.** Most failed requests are retried up to eight times, and connection drops are retried up to twenty times. A part of a multipart upload is considered failed and retried if there is no progress for 60 seconds.
- **Expired signatures.** If the storage service returns a 403 response, the client will request new URLs from our route and continue the upload.
- **Reloads.** For multipart uploads, the client stores progress in `localStorage`. If the guest selects the same file again, the upload will resume. This is on a best-effort basis and does not work in private browsing tabs.

CORS failures, where the request fails before any bytes are sent, are retried only three times. Retrying will not fix CORS, and the error message will indicate this.

The following must be handled on your side:

- **Repeat callbacks.** `onUploadComplete` [can be called more than once](https://upstash.com/docs/blob/uploads/upload-handler) for the same upload. You should use the `uploadId` as the key for database records, just like our Redis hash does.
- **Abandoned uploads.** If a guest closes the tab while an upload is in progress, some parts of the multipart upload may remain in the bucket. These can be [removed by a daily cron job](https://upstash.com/docs/blob/uploads/large-files) that calls `bucket.abortStaleMultipartUploads()`. The age cutoff for this job should be set to a value greater than the expected duration of the slowest upload.
- **Untrusted content.** The server compares the first few bytes of a file with its declared type, but this check [is for convenience only and is not a security control](https://upstash.com/docs/blob/uploads/constraints). It does not scan for malware.
- **SVG files.** The `image/*` MIME type does not include SVG files, as they can contain scripts. You should only add `image/svg+xml` to the list of allowed types if you are sure that you can serve those files safely.

## How do the options compare on cost and fit?

Upstash Blob and Cloudflare R2 are the lowest-cost options for a React app serving less than 1 TB of traffic each month, since neither charges for egress up to this limit. Upstash Blob also provides React upload hooks and the server handler we used above, which means we didn't need to use any extra upload library for the whole photo-drop flow.

Here is how the options compare on list price:

| Option | Storage | Egress (downloads) | Free tier |
| --- | --- | --- | --- |
| [Upstash Blob](https://upstash.com/pricing/blob) | $0.02/GB | Free up to 1 TB/month, then $0.02/GB | 1 GB storage, 10 GB bandwidth |
| [UploadThing](https://uploadthing.com/pricing) | $10/month for 100 GB, $25 for 250 GB, then $0.08/GB | No per-GB download fee listed | 2 GB |
| [Vercel Blob](https://vercel.com/docs/vercel-blob/usage-and-pricing) | $0.023/GB | $0.05/GB on demand, or covered by Pro's Flat Rate CDN (1 TB included) | 1 GB storage, 10 GB transfer (Hobby) |
| [Cloudflare R2](https://developers.cloudflare.com/r2/pricing/) | $0.015/GB | Free | 10 GB storage |
| [AWS S3](https://aws.amazon.com/s3/pricing/) | $0.023/GB | $0.09/GB direct, or first 1 TB free through CloudFront | 100 GB egress, shared across AWS |
| [Cloudinary](https://cloudinary.com/documentation/developer_onboarding_faq_credits) | 1 credit per GB | 1 credit per GB | 25 credits |

For a photo album that stores 100 GB and serves 1 TB a month, storage plus egress at list price comes to $2.00 on Upstash Blob, $1.50 on R2, and $2.30 on S3 behind CloudFront, thanks to [CloudFront's free 1 TB](https://aws.amazon.com/cloudfront/pricing/pay-as-you-go/). Vercel Blob at on-demand rates comes to $52.30:

![](https://cdn.bydefault.so/chart-orig-EvebX3zzWzEOnqRVBfKbg.png)

Upstash Blob is great for a plain React SPA. You get typed React hooks and a framework-free handler that can be mounted on Hono (or basically anywhere else). There's one global bucket, so you don't need to select a region, and egress up to 1 TB a month is free. However, if you exceed the free tier limits, [the bucket stops serving requests](https://upstash.com/pricing/blob) until the 30-day window rolls over.

The other options fit better in these cases:

- **Cloudflare R2** is the best option for sites with many terabytes of traffic. At 1 TB stored and 10 TB served, [R2 costs $33.75 and Upstash Blob costs $224](https://upstash.com/blog/whats-the-best-object-storage-for-a-saas-september-2026-comparison). You write the presigned URL and progress code yourself.
- **UploadThing** is best when you want a ready-made upload widget and a flat monthly bill more than control over the UI.
- **Cloudinary** is best when your app needs to resize, crop, or transcode media on the fly. One credit buys [1 GB of storage, 1 GB of bandwidth, or 1,000 transformations](https://cloudinary.com/documentation/developer_onboarding_faq_credits).
- **S3** is best when you're already running your other services on AWS and your egress bill is small.

---

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.