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.
lib/uploads.ts
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
app/api/upload/route.ts
lib/upload-hooks.ts
app/page.tsx
/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.
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
lib/uploads.ts
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
lib/uploads.ts
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
lib/uploads.ts
begin, before anything is signed and before any bytes exist. It decides whether the upload happens and where the object goes.
What it returns:
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
lib/uploads.ts
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
Returnconstraints 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
lib/uploads.ts
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
lib/uploads.ts
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, typed through uploadHooks<typeof uploads>:
app/page.tsx
Retries and throws
lib/uploads.ts
end leaves an object no callback recorded. See Abandoned uploads.
onError
lib/uploads.ts
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
lib/uploads.ts
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
lib/uploads.ts
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.lib/callbacks.ts
Multiple routes
lib/uploads.ts
app/page.tsx
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()
lib/uploads.ts
app/page.tsx
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.