A React app whose backend is Python: users pick files, the bytes go from the browser straight to storage, and FastAPI only decides who may upload and records what landed.
Three choices make that work:
- An
AsyncUploadHandlermounted on FastAPI. It speaks the same protocol as the TypeScript handler, souseUploadfrom@upstash/blob/reactworks against it unchanged. - One auth check for every route.
require_useris both the handler’scontextand the FastAPI dependency on the other endpoints, andownerbinds each upload to the user who began it. - One origin. Vite proxies
/apito FastAPI in development, and FastAPI serves the built app in production, so the session cookie reaches the handler with no CORS setup.
This recipe uses a public bucket. If you have not created one yet, start with the Quickstart.
Setup#
The backend lives in app/, the React app in web/.
The handler#
The handler takes images and PDFs up to 25 MB and refuses anything else before a byte is uploaded. It runs on FastAPI’s event loop, so it is an AsyncUploadHandler over an AsyncBucket; see Sync or async.
require_user runs on every POST to the route. Its HTTPException(401) reaches the browser as a BlobError with code unauthorized, so the React side can tell a dead session from a refused file. owner signs the user id into each upload, so a second signed-in user holding the completion token cannot finish or cancel it. See context and owner.
unique_path adds a random suffix to the filename, so two uploads of photo.png are two objects. args.file.name is the original filename, and the completion callback is the only place it survives, so the row stores it.
A raise out of record deletes the object, so the except turns a database failure into a refusal the user can retry from. not_ready is the 503 code, the one that means try again. See Retries and throws.
The routes#
/api/upload is where the hooks look by default. GET serves the route’s limits to the file picker and POST runs the upload. /api/files reads your rows, never bucket.list(): the bucket cannot answer “which files are this user’s”, and your table can.
Delete the row first, then the object. The list stops showing the file immediately, and if the second step fails the leftover is an object nobody links to rather than a link that 404s. A retried DELETE finds no row and returns 404, so clear such leftovers in a sweep that compares bucket.list(prefix="files/") with your table.
The proxy#
In development the page is served by Vite on port 5173, and every /api request is forwarded to FastAPI. The browser sees one origin, so the session cookie is sent with each upload request. In production, npm run build writes web/dist, which app/main.py serves.
A frontend on another origin needs CORS on FastAPI, and either a cookie that crosses origins or a token in headers.
The picker#
useUpload is imported straight from @upstash/blob/react, with the handler’s URL. There is no uploadHooks<typeof uploads> here: that type comes from a TypeScript handler. So upload.blob.data is unknown, and the list is refreshed from /api/files in onDone rather than read off the record. See With a server in another language.
accept and constraints come from the route’s GET, so the file dialog offers only images and PDFs and the page shows the same limit the server enforces. A file over 25 MB is refused in the browser before any request. Files over the multipart threshold go up in parts, with no change on either side.
Showing files#
url is the public URL, which is right for a public bucket. If a file must not be readable by anyone holding its URL, put it on a private bucket and return a signed read URL from /api/files instead.
Cleanup#
A user can close the tab halfway through an upload, and nothing tells your server. Under the multipart threshold that leaves a stored object with no row; over it, unfinished parts. A daily job clears the parts:
Abandoned uploads has the single-PUT half: a pending row written in the before-upload callback, or multipart=True on the handler so every upload leaves parts rather than an object.