New:Upstash has a remote MCP server
·13 min read

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

JoshJoshDevRel @Upstash
https://upstash.com/blog/react-file-upload-a-multi-file-drag-and-drop-uploader-with-upstash-blob

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.

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:

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. 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. It's great when you want a drop-in widget and a flat monthly bill.
  • Object storage with direct uploads. Upstash 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 covers the same idea with route handlers. If you mainly want prices side by side, our React upload cost breakdown 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. 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. 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.

The full round trip looks like this:

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:

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, plus UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN for the album records. We store those records in Upstash Redis, one hash per album.

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

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. 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:

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:

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:

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:

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:

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:

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. 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 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 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. 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:

OptionStorageEgress (downloads)Free tier
Upstash Blob$0.02/GBFree up to 1 TB/month, then $0.02/GB1 GB storage, 10 GB bandwidth
UploadThing$10/month for 100 GB, $25 for 250 GB, then $0.08/GBNo per-GB download fee listed2 GB
Vercel Blob$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$0.015/GBFree10 GB storage
AWS S3$0.023/GB$0.09/GB direct, or first 1 TB free through CloudFront100 GB egress, shared across AWS
Cloudinary1 credit per GB1 credit per GB25 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. Vercel Blob at on-demand rates comes to $52.30:

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 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. 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.
  • S3 is best when you're already running your other services on AWS and your egress bill is small.