uploadHandler 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 handler runs onBeforeUpload at the begin phase, before anything is signed, and onUploadComplete at the end phase, once the object exists. The PUTs in between go to storage, not to you. The phases are listed on Types.


Mounting it#
The handler is a pair of route handlers. The client assumes it is mounted at /api/upload; endpoint on uploadHooks or useUpload changes that. uploadHooks<typeof uploads> binds the client to the handler's type, so route names and completion data are checked at compile time. Upload client has the hooks in full.
Other frameworks#
GET and POST are plain (request: Request) => Promise<Response> functions, so any framework that hands you a fetch Request can mount them.
Frameworks built on Node's http module, such as Express, need an adapter that converts the incoming request into a fetch Request first.
If the bytes have to pass through your app instead, write an ordinary route that calls bucket.put and drive it with useServerUpload.
Handler options#
| Option | Type | Default | Description |
|---|---|---|---|
bucket | Bucket | Bucket.fromEnv(), from UPSTASH_BLOB_TOKEN | The bucket every route writes to. |
constraints | { contentTypes?, maxSize? } | none, anything | What the route accepts. Served by GET and enforced at begin. |
multipart | boolean | Size | '16mb' | Where an upload stops being one PUT and starts going up in parts. |
endpoint | string | none | Where the handler is mounted. Only needed to separate two handlers on one bucket. |
context | (request: Request) => TCtx | none, ctx is undefined | Runs once per POST. Its value is ctx in every callback. |
input | Standard Schema | none | Validates what the browser sends as input before onBeforeUpload runs. Needs uploadRoute(). |
onBeforeUpload | (args) => { path, ... } | required, here or on every route | Authorizes the upload and names the path. |
onUploadComplete | (args) => TData | none | Records the object. What it returns becomes upload.blob.data. |
onError | (args) => BlobError | Response | void | none | Sees every refusal. The one place to log. |
routes | Record<string, route> | none, one unnamed route | Mounts 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 merges one level deeper; see Per-route constraints.
onBeforeUpload is the only required callback. Bad options throw at startup, where they are written: an unparseable multipart size, an invalid route name, an empty routes map, a missing token.
The bucket#
With no bucket, the handler reads UPSTASH_BLOB_TOKEN and builds one bucket for every route, like Bucket.fromEnv(). Pass bucket: when the token lives under another variable, the bucket needs cache, or you are on Cloudflare Workers.
onBeforeUpload#
Runs at begin, before anything is signed and before any bytes exist. It decides whether the upload happens and where the object goes.
| Argument | Type | Description |
|---|---|---|
ctx | TCtx | Whatever context returned for this request. |
route | string | The route this file was sent to. '' when the handler mounts no named routes. |
request | Request | The begin request, headers and cookies intact. |
file | { name, type, size } | What the browser declared, before a byte was sent. |
input | TInput | The validated input, when the route declares a schema. |
What it returns:
| Field | Type | Default | Description |
|---|---|---|---|
path | string | required | Where the object is stored. |
cache | CacheOption | the bucket default | The Cache-Control this object is stored with. See Caching. |
metadata | Record<string, string> | none | Signed into the upload and handed back to onUploadComplete. |
constraints | { contentTypes?, maxSize? } | the route's | Narrows this one upload's limits. |
state | TState | undefined | Anything computed here that onUploadComplete and onError should get without a lookup. Needs uploadRoute(). |
file is what the browser declared, and file.type is what the object is stored and served as. Whether the bytes match is checked separately; see Byte sniffing.
Paths#
path is required, and may not contain . or .. segments. Build it with uniquePath, which turns every interpolated value into a slugged filename with a random suffix, so a browser filename can never add a directory. The rules are on Writing.
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 onUploadComplete, is stored on the object, and is readable later with bucket.info(path). Same rules as a server-side write: lowercase keys, printable ASCII values. See Metadata.
metadata["upstash-upload"] is reserved for the SDK and throws invalid_input.
Narrowing per user#
Return constraints to tighten the route's limits for this one upload, for example a smaller maxSize on a free plan. It can only make the route stricter. See Narrowing per user.
Refusing#
Throw a BlobError to refuse. 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.
onUploadComplete#
Runs at end, once the object exists. It receives the completed object's fields plus everything this route knew about the upload.
| Argument | Type | Description |
|---|---|---|
path | string | Where the object is stored. |
url | string | undefined | The public URL. undefined on a private bucket. |
versionedUrl | string | undefined | url with the etag on the query. For a stable path that gets overwritten. |
size | number | Bytes actually stored, verified against what the browser declared. |
etag | string | The stored object's etag. |
uploadedAt | Date | When storage wrote it. |
contentType | string | What the object is stored as. This is the one to record. |
ctx | TCtx | What context returned for this request. |
route | string | The route name, '' for a sole route. |
request | Request | The end request. |
file | { name, type, size } | What the browser declared at begin. The original filename survives only here. |
uploadId | string | Identifies this upload. Stable across retries: the idempotency key. |
multipartUploadId | string | undefined | For bucket.abortMultipartUpload(). undefined for a single PUT. |
metadata | Record<string, string> | What onBeforeUpload returned, minus the SDK's marker. |
state | TState | What onBeforeUpload returned as state. |
What it returns is handed to the browser as upload.blob.data, typed through uploadHooks<typeof uploads>:
Retries and throws#
It may run more than once. The browser retries end on a network failure, so upsert on uploadId, which is stable across retries, rather than inserting a new row.
A throw deletes the object. 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.
onError#
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.
| Argument | Type | Description |
|---|---|---|
ctx | TCtx | undefined | undefined when context itself threw, or when no route matched. |
route | string | The name from the query, even when nothing mounts it. |
request | Request | |
error | unknown | Whatever was thrown. |
file, path, metadata, state | optional | As much 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, before the route is picked and before any body is read. Its awaited value is ctx in every callback, and typed there. It does not run for GET, which serves a public constraints document.
Use it when several routes share one auth check, or when onUploadComplete and onError need an authenticated value. With a single route, authorizing inside onBeforeUpload and carrying an id in metadata is shorter.
Write context above routes
Write context above routes, or annotate its parameter as (request: Request) =>. Written below routes with an unannotated parameter, TypeScript infers ctx: undefined for the routes first and reports the error on context: Promise<Session> is not assignable to undefined.
For a callback written in another file, annotate the argument with the helper types, which take the handler and produce the right shape:
Multiple routes#
routes mounts 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
onError. - An upload authorized by one route cannot be completed at another.
- A handler with no
routesis itself the route. It is reached with no?route=, and the bounduseUpload()takes no argument. - Two handlers on the same bucket that mount the same route names need an
endpointto tell them apart.
uploadRoute()#
Use uploadRoute() when a route needs an input schema for data the browser sends along with the file, or a typed state passed from onBeforeUpload to onUploadComplete. A plain route object cannot type either. It is curried so the ctx type can be named, and it is written outside the routes map.
inputis validated beforeonBeforeUploadruns, and only the parsed value reaches it. A route with no schema refuses anyinputthe browser sends withinvalid_input. Validation failures areinvalid_inputtoo, with the issues joined aspath: message, so a badthreadIdreadsthreadId: Invalid uuid.stateis 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.- Everything else on the route works as on a plain object:
bucket,constraints,multipart,onError, and the same inheritance from the handler.
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.