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 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#

OptionDefaultDescription
Bucketone bucket built from UPSTASH_BLOB_TOKENThe bucket every route writes to. Pass one when the token lives under another variable, the bucket needs a default cache, or the platform has no environment variables.
Constraintsnone, anythingWhat the route accepts. Served by GET and enforced at begin. See Constraints.
Multipartthe multipart thresholdWhere an upload stops being one PUT and starts going up in parts.
EndpointnoneWhere the handler is mounted. Only needed to separate two handlers on one bucket.
ContextnoneRuns once per POST. Its value reaches every callback. See Context.
InputnoneA schema that validates what the browser sends as input before the before-upload callback runs. See Input and state.
Before-upload callbackrequired, here or on every routeAuthorizes the upload and names the path.
Completion callbacknoneRecords the object. What it returns becomes upload.blob.data in the browser.
Error callbacknoneSees every refusal. The one place to log.
Routesnone, one unnamed routeMounts several routes at this one endpoint.

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:

FieldDefaultDescription
PathrequiredWhere the object is stored.
Cachethe bucket defaultThe Cache-Control this object is stored with. See Caching.
MetadatanoneSigned into the upload and handed back to the completion callback.
Constraintsthe route’sNarrows this one upload’s limits.
StatenoneAnything computed here that the completion and error callbacks should get without a lookup. See Input and state.

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:

FieldDescription
PathWhere the object is stored.
URLThe public URL. Not set on a private bucket.
Versioned URLThe URL with the etag on the query. For a stable path that gets overwritten.
SizeBytes actually stored, verified against what the browser declared.
EtagThe stored object’s etag.
Uploaded atWhen storage wrote it.
Content typeWhat the object is stored as. This is the one to record.
ContextWhat context returned for this request.
RouteThe route name.
RequestThe end request.
FileWhat the browser declared at begin. The original filename survives only here.
Upload idIdentifies this upload. Stable across retries: the idempotency key.
Multipart upload idFor aborting this exact multipart upload later; see Aborting one upload. Not set for a single PUT.
MetadataWhat the before-upload callback returned, minus the SDK’s marker.
StateWhat the before-upload callback returned as state.

What it returns is handed to the browser as upload.blob.data.

Retries and throws#

Warning

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.