Skip to main content
An upload handler is the server side of a direct browser upload. It authorizes the upload, signs it, and records what landed. The bytes go from the browser straight to storage and never touch your server, so uploads are not limited by your platform’s request body cap, the 4.5MB one on Vercel or the 6MB one on AWS Lambda.
The handler runs a before-upload callback at the begin phase, before anything is signed, and a completion callback at the end phase, once the object exists. The PUTs in between go to storage, not to you. The phases are listed on Types. Option names, argument types and framework wiring: TypeScript, Python, Go.
Client upload flowClient upload flow

Mounting it

The handler answers GET and POST at one URL. POST runs the upload phases and GET serves the route’s constraints. The browser client assumes /api/upload and can be pointed anywhere else. It speaks the same protocol to every server SDK, so the frontend does not change with the backend language. Mounting in other frameworks: TypeScript, Python, Go. If the bytes have to pass through your app instead, write an ordinary route that calls put and drive it with useServerUpload.

Handler options

Everything except routes, endpoint and context is a default that each route inherits. A route overrides the keys it names, so a handler with five routes states the shared policy once. Constraints merge one level deeper; see Per-route constraints. The before-upload callback is the only required one. Bad options fail at startup, where they are written: an unparseable multipart size, an invalid route name, an empty routes map, a missing token.

Authorizing an upload

The before-upload callback runs at begin, before anything is signed and before any bytes exist. It decides whether the upload happens and where the object goes. It receives the context value, the route name, the begin request with its headers and cookies, the file the browser declared (name, type and size, before a byte was sent), and the validated input when the route declares a schema. It returns: The declared file type is what the object is stored and served as. Whether the bytes match is checked separately; see Byte sniffing.

Paths

The path is required, and may not contain . or .. segments. The unique path helper preserves IDs and filenames, including case, underscores, spaces, and Unicode, and adds one random suffix to the final filename. Slashes anywhere in the string are directories, so a prefix can be included as is. The rules are on Unique paths. A stable path is an overwrite. Two concurrent uploads to the same path race, and the loser’s end can fail with not_found. Use a stable path only when overwriting is the intent.

Metadata

Metadata is signed into the presigned PUT, so the browser cannot add to it or change it. It comes back on the completion callback, is stored on the object, and is readable later with info. Same rules as a server-side write: lowercase keys, printable ASCII values. See Metadata. The key upstash-upload is reserved for the SDK and is refused with invalid_input.

Narrowing per user

Return constraints to tighten the route’s limits for this one upload, for example a smaller max size on a free plan. They can only make the route stricter. See Narrowing per user.

Refusing

Refuse with a BlobError (a returned *blob.Error in Go). Nothing is signed and no URL is handed out. The error reaches the browser with its code intact, so a hook can switch on error.code instead of status numbers. The codes are on Errors. The browser never retries begin, so a callback that writes a row runs once per file.

Recording the upload

The completion callback runs at end, once the object exists. It receives the completed object’s fields plus everything this route knew about the upload: What it returns is handed to the browser as upload.blob.data.

Retries and throws

It may run more than once. The browser retries end on a network failure, so upsert on the upload id, which is stable across retries, rather than inserting a new row.A throw deletes the object, and so does a returned error in Go. That is the intent for a refusal, and a trap for a database error: a short outage destroys bytes that uploaded fine. Catch your own storage errors rather than letting them escape.
A browser that dies before end leaves an object no callback recorded. See Abandoned uploads.

Handling errors

The error callback sees every refusal this endpoint produces, including the handler’s own and a request for a route nobody mounted. Log here and keep the other callbacks about the happy path. It receives the context value (not set when context itself failed, or when no route matched), the route name from the query even when nothing mounts it, the request, the error, and as much of the file, path, metadata and state as the request had reached before it failed. Return a BlobError or a response to answer with it. Return nothing and the answer is left alone. Written on the handler it is the default for every route; a route with its own replaces it. How a thrown error becomes a response is on Errors.

Context

Context runs once per POST, after the route is picked and before any body is read. Its value reaches every callback. It does not run for GET, which serves a public constraints document. Use it when several routes share one auth check, or when the completion and error callbacks need an authenticated value. With a single route, authorizing inside the before-upload callback and carrying an id in metadata is shorter.

Multiple routes

app/page.tsx
Routes mount several routes at one endpoint. The name travels in the query as ?route=avatar, and the client passes the name instead of a URL.
  • Route names must match /^[A-Za-z_][\w-]*$/, checked when the handler is built.
  • An unknown name is a 404 that does not list the routes the handler mounts. It still reaches the error callback.
  • An upload authorized by one route cannot be completed at another.
  • A handler with no routes is itself the route. It is reached with no ?route=.
  • Two handlers on the same bucket that mount the same route names need an endpoint to tell them apart.

Input and state

app/page.tsx
A route can take input, data the browser sends along with the file, and pass state from the before-upload callback to the completion callback.
  • input is validated before the before-upload callback runs, and only the parsed value reaches it. A route with no schema refuses any input the browser sends with invalid_input. Validation failures are invalid_input too, with the issues joined as path: message, so a bad threadId reads threadId: Invalid uuid.
  • state is for values the callback already computed and does not want to look up again. It travels through the browser and is readable in devtools, so put a row id there, never a secret. See The completion token.
How a route declares its schema and types its state: TypeScript, Python, Go.

The GET endpoint

GET on the route serves its constraints as JSON. That is what fills accept and constraints on the hook, and it lets the hook refuse an oversized file before any request leaves the browser. The server still enforces the same limits at begin. The document and its caching are on Constraints.