# Use upup with AI assistants
https://useupup.com/docs/ai-assistants/
Coding agents choose a library by retrieving the docs page that answers the exact
question a developer typed, then running its quickstart. This page makes upup easy
for an assistant to discover and integrate correctly on the first try.
## Machine-readable context
upup publishes machine-readable context files that a coding agent can fetch
directly:
- **`https://useupup.com/llms.txt`** — a concise index of upup, its packages, and
the key docs pages.
- **`https://useupup.com/llms-full.txt`** — the fuller context dump
for agents that can take more input.
Point your assistant at either URL when you want it to work with upup.
## Paste-ready context block
Drop this into your project's assistant config — `CLAUDE.md`, `.cursorrules`,
`AGENTS.md`, or similar — so your agent has an accurate, compact model of upup:
```text
# upup — MIT self-hosted file uploader (docs: https://useupup.com/docs/)
upup is one headless core plus native, DOM-identical UI packages for six frameworks.
Nine published @upupjs/* packages:
- @upupjs/core headless engine: file state, upload pipeline (compression, HEIC, web-worker), cloud-drive plugins, i18n, theme. Zero framework deps.
- @upupjs/react canonical UI (React 19). @upupjs/vue, @upupjs/svelte, @upupjs/angular, @upupjs/vanilla, @upupjs/preact are native ports with the same DOM.
- @upupjs/next Next.js client re-export + /server route handlers (App and Pages routers).
- @upupjs/server server-mode endpoints: S3-compatible presign + proxy, cloud-drive token exchange, HMAC-signed upload-token trust model.
Client mode (default): the browser uploads straight to your storage; your app returns presigned URLs at `uploadEndpoint`. No server package required.
React example: import { UpupUploader } from '@upupjs/react'; import '@upupjs/react/styles'
Server mode: point the uploader at @upupjs/server with mode="server" serverUrl="/api/upup".
createUpupHandler({ storage: { type: 'aws', bucket, region }, uploadTokenSecret })
— uploadTokenSecret is REQUIRED and must be >= 16 chars, or it throws at construction.
Sources: local drag-and-drop, URL/link import, camera, screen capture, and cloud drives (Google Drive, OneDrive, Dropbox, Box).
Optional: image compression, HEIC conversion, resumable uploads (tus or S3 multipart), ICU i18n, theming. Image editor is React/Preact only.
Quickstarts: https://useupup.com/docs/quickstarts/
```
Every line above is verified against the packages as they ship — a snippet from
it will run rather than throw.
## Example prompt
Once the context is in place, a request like this is enough:
> Add file uploads to my SvelteKit app with upup. Use client mode with a presigned
> upload endpoint, enable the Google Drive and camera sources, and show me the
> component plus the upload-token route.
The assistant has what it needs: install `@upupjs/svelte`, import the component and
its stylesheet, render ``,
and add the `sources` and `cloudDrives` config.
## Next steps
- Framework quickstarts: [React](/docs/quickstarts/react/), [Vue](/docs/quickstarts/vue/),
[Svelte](/docs/quickstarts/svelte/), [Angular](/docs/quickstarts/angular/),
[Vanilla JS](/docs/quickstarts/vanilla/), [Preact](/docs/quickstarts/preact/),
[Next.js](/docs/quickstarts/next/).
- [Server Mode — Setup](/docs/guides/server-mode-setup/) for the full server walkthrough.
---
# Azure SAS Responses
https://useupup.com/docs/api-reference/azure-generate-sas-url/
Client uploads can use any endpoint that returns upup's presign contract.
```ts
type PresignedUrlResponse = {
key: string
uploadUrl: string
uploadHeaders?: Record
publicUrl?: string
downloadUrl?: string
expiresIn: number
}
```
Return headers that the browser must send with the signed request:
```ts
return Response.json({
key,
uploadUrl: sasUrl,
uploadHeaders: {
'x-ms-blob-type': 'BlockBlob',
'Content-Type': body.type || 'application/octet-stream',
},
expiresIn: 3600,
})
```
---
# Error Codes
https://useupup.com/docs/api-reference/error-codes/
Most failures upup raises are an `UpupError` (or one of its six subclasses)
carrying a `code` from the `UpupErrorCode` enum. Codes are stable strings — safe
to switch on, log, and tag in an error tracker. This page is the complete list;
[Error Handling](/docs/error-handling/) covers the narrative, and [Error
Monitoring](/docs/guides/error-monitoring/) covers wiring failures into Sentry
or your own reporter.
The one important exception is the rejection from `upload()` itself — see
[Batch failures](/docs/api-reference/error-codes/#batch-failures-are-not-upuperrors)
before you write a handler, because it is **not** an `UpupError` and carries no
`code`.
Every class that carries a code, and the one rejection that sits outside them:
## The complete code list
`UpupErrorCode` has 24 members. `retryable` is the value the error object
carries on the `retryable` field — see [Retryability](/docs/api-reference/error-codes/#retryability)
below for what actually drives automatic retries.
| Code | Meaning | Typical trigger | Retryable |
| ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| `AUTH_REQUIRED` | Anonymous uploads are disabled | `@upupjs/server` `/presign` or `/multipart/init` reached with no `auth`, no `getUserId`, and no `allowAnonymousUploads` — returns 403 | no |
| `AUTH_DENIED` | The upload token belongs to a different user | A multipart continuation route (sign-part / complete / abort / resume) called with a token whose `uid` is not the current user — returns 403 | no |
| `AUTH_EXPIRED` | A stored drive credential is dead or revoked | The server's drive-token refresh call is rejected by the provider; the stored tokens are deleted and the user must re-authorize. Surfaces only through the server's `onError` reporter — it is never returned as a response-body code | no |
| `AUTH_PROVIDER_ERROR` | A cloud-drive OAuth or provider call failed | OAuth code-for-token exchange returns non-2xx (server answers 502). Also the code every `UpupAuthError` carries by construction | no |
| `FILE_TOO_LARGE` | A file is over the per-file size ceiling | The file's size exceeds `maxFileSize` | no |
| `FILE_TOO_SMALL` | A file is under the per-file size floor | The file's size is below `minFileSize` | no |
| `TYPE_MISMATCH` | A file's type is not accepted | Neither the MIME type nor the extension matches `accept` | no |
| `LIMIT_EXCEEDED` | Too many files | The incoming batch would push the selection past `maxFiles` | no |
| `TOTAL_SIZE_EXCEEDED` | The selection is too large in aggregate | Existing plus incoming bytes exceed `maxTotalFileSize` | no |
| `DUPLICATE` | A content-identical file is already selected | Reserved. With `contentDeduplication` on, duplicates are dropped silently rather than raised — no code path emits this today | no |
| `MIN_FILES_NOT_MET` | Fewer files selected than required | Reserved — no code path emits this today | no |
| `UPLOAD_FAILED` | The upload could not be performed | Exactly one emitter: the resumable tus strategy is selected but the optional `tus-js-client` dependency is not installed. Despite the generic name, no other path raises it | no |
| `UPLOAD_ABORTED` | The upload was cancelled | Reserved. Aborts currently surface as an `UpupNetworkError` ("Upload aborted") carrying `NETWORK_ERROR` | no |
| `PRESIGN_FAILED` | Presigning the object URL failed | The server's `/presign` route threw — bad storage credentials, unreachable S3 endpoint, rejected key — returns 500 | no |
| `CORS_ERROR` | The bucket's CORS configuration blocked the request | Reserved for bucket-CORS diagnosis; no code path emits it today, but the i18n map already routes it to a "CORS misconfigured" message | no |
| `PIPELINE_STEP_FAILED` | A processing-pipeline step threw | Reserved — the HEIC step reports through `HEIC_CONVERSION_FAILED`, and no other step emits this today | no |
| `HEIC_CONVERSION_FAILED` | A HEIC/HEIF image could not be decoded | The optional `libheif-js` dependency is not installed, or the file contains no decodable image | no |
| `NETWORK_ERROR` | The transport itself failed | An XHR `error` or `abort` event during a direct PUT, or any `UpupNetworkError`. Note that the error thrown once retries are exhausted is the last _real_ failure, which is often an `UpupStorageError`, not this code | **yes** |
| `TIMEOUT` | An operation ran past its deadline | Reserved — no code path emits this today | no |
| `STORAGE_ERROR` | An S3 / S3-compatible storage operation failed | A direct PUT returning non-2xx, any multipart init / sign-part / complete / abort / resume failure, a failed drive list or transfer, or the server router's last-resort 500. Also reported (not returned) when a post-completion hook throws after a durably-completed upload the client already got a 200 for, and when the health probe finds storage unreachable | class-dependent |
| `QUOTA_EXCEEDED` | A configured storage quota is exhausted | The code every `UpupQuotaError` carries by construction | no |
| `NO_UPLOAD_TARGET` | No upload destination is configured | The default code for `UpupConfigError` — no `uploadEndpoint` / provider wiring to upload to | no |
| `BAD_REQUEST` | The request or API call is malformed | Server-side: an unparseable JSON body or invalid file metadata (400). Client-side: calling `addFiles` / `setFiles` / `upload` / `resume` / `retry` after `destroy()`, or passing an unknown file ID to `replaceFile` / `reorderFiles` | no |
| `NOT_FOUND` | The addressed resource is gone server-side | `@upupjs/server` `/multipart/resume` asked to re-attach to a multipart upload the storage provider no longer has — completed, aborted, or reaped by an `AbortIncompleteMultipartUpload` lifecycle rule — returns 404. Deliberately a 4xx: the client drops its saved session and starts a fresh upload rather than retrying something that cannot return | no |
Codes marked _reserved_ are part of the published enum and safe to reference in
your own `switch` statements — they simply have no emitter in upup today.
### Retryability
`retryable` is a per-class constant, not a per-code lookup: `UpupNetworkError`
sets it to `true` and every other class sets it to `false`.
Crucially, **the code and the flag come from different places**, so you cannot
infer one from the other. `uploadErrorFromResponse` picks the class from its
`kind` argument — which fixes `retryable` — and then overwrites `code` with
whatever machine code the response body supplied. A code can therefore ride on a
class it does not "belong" to. The server-side drive transfer is a real example:
it builds its error with `kind: 'network'` (so `retryable` is `true`), and the
server answers a failed drive request with `STORAGE_ERROR` in the body, so the
client genuinely sees `STORAGE_ERROR` with `retryable: true`.
Never branch on `code` to decide whether something is retryable, or vice versa.
Read `retryable` for the flag and `code` for the cause.
Automatic retries do **not** consult that flag. The upload manager retries every
failed attempt up to `maxRetries` times except three cases, which fail
immediately:
- the upload was aborted,
- the failure is an `UpupNetworkError` with a 4xx status (a client error — a
retry would fail identically),
- the failure is an `UpupNetworkError` with status `0`, which is how a rejecting
`isSuccessfulCall` is reported.
Treat `retryable` as a hint for your own UI and reporting logic, and
[`maxRetries`](/docs/api-reference/upupuploader/optional-props/#maxretries) as
the control for upup's own retry loop.
## The error classes
Seven classes, all exported from `@upupjs/core`. Each subclass fixes the `code`
it carries (except `UpupConfigError`, whose code is overridable) and adds the
fields you need to act on the failure.
| Class | Code it carries | Thrown when | Extra fields |
| --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| `UpupError` | whatever the caller passes | Base class. Raised directly for lifecycle and API misuse (`BAD_REQUEST`), HEIC decode failures, and a missing optional upload dependency | `code`, `retryable`, `status?` |
| `UpupAuthError` | `AUTH_PROVIDER_ERROR` | A cloud-drive OAuth or provider call fails; also what `uploadErrorFromResponse` builds for `kind: 'auth'` | `provider` |
| `UpupNetworkError` | `NETWORK_ERROR` | Transport failure or abort — the only class that is `retryable` | `status?` |
| `UpupValidationError` | the restriction reason itself | A file fails a size, type, or count check | `reason`, `file` |
| `UpupQuotaError` | `QUOTA_EXCEEDED` | A configured quota is exceeded | `limit`, `used` |
| `UpupStorageError` | `STORAGE_ERROR` | An S3 / storage operation fails; also what `uploadErrorFromResponse` builds for `kind: 'storage'` | `provider`, `operation` |
| `UpupConfigError` | `NO_UPLOAD_TARGET` by default, overridable via the constructor | Configuration is missing or invalid | — |
`UpupStorageError.operation` is one of `'presign'`, `'upload'`,
`'multipart-init'`, `'multipart-complete'`, `'multipart-sign-part'`,
`'multipart-abort'`, or `'multipart-resume'` — enough to tell a failed presign
apart from a failed part upload without parsing messages.
### Batch failures are not `UpupError`s
When one or more files in a run fail, `upload()` rejects with an
`UpupUploadBatchError`. It extends plain `Error`, **not** `UpupError` — it has no
`code` and no `retryable`. This is the single most common rejection you will
handle, so a handler that only checks `instanceof UpupError` will fall through to
its "unknown error" branch on the ordinary failure path.
The batch error is a wrapper, and the coded per-file errors are inside it:
```typescript
readonly errors: { file: UploadFile; error: Error }[]
```
Each `error` in that array is the real, coded failure for that file — an
`UpupStorageError`, `UpupNetworkError`, and so on. The same per-file errors also
arrive individually on the `upload-error` event as they happen, which is usually
the better place to react; the batch rejection is best treated as "the run did
not fully succeed" rather than as a diagnosis.
One caveat when narrowing: `UpupUploadBatchError` is **not exported** from
`@upupjs/core` or `@upupjs/core/internal`, so you cannot `instanceof` it. Detect
it by name or by shape:
```typescript
function isBatchError(
error: unknown,
): error is Error & { errors: { file: File; error: Error }[] } {
return error instanceof Error && error.name === 'UpupUploadBatchError'
}
```
### Narrowing by class, then by code
`instanceof` gets you the extra fields; the `code` gets you the specific cause.
Reach for the class first, because that is what makes `error.file` or
`error.operation` type-safe:
```typescript
import {
UpupError,
UpupErrorCode,
UpupNetworkError,
UpupStorageError,
UpupValidationError,
} from '@upupjs/core'
function report(error: unknown): string {
// Check this FIRST — the rejection from upload() is a batch wrapper that is
// not an UpupError, so every instanceof below would miss it.
if (isBatchError(error)) {
return error.errors.map(entry => report(entry.error)).join('; ')
}
if (error instanceof UpupValidationError) {
// `reason` is narrowed to RestrictionFailedReason
return `${error.file.name} rejected (${error.reason})`
}
if (error instanceof UpupStorageError) {
return `S3 ${error.operation} failed on ${error.provider}`
}
if (error instanceof UpupNetworkError) {
return error.status === undefined
? 'Connection dropped.'
: `Transport failed with HTTP ${error.status}.`
}
if (error instanceof UpupError) {
switch (error.code) {
case UpupErrorCode.PRESIGN_FAILED:
return 'Your token endpoint returned an error.'
case UpupErrorCode.AUTH_REQUIRED:
case UpupErrorCode.AUTH_DENIED:
return 'Sign in again to upload.'
case UpupErrorCode.HEIC_CONVERSION_FAILED:
return 'This HEIC image could not be converted.'
default:
return error.retryable
? 'Transient failure — safe to retry.'
: 'Permanent failure — needs attention.'
}
}
return 'Unknown error.'
}
```
## Restriction reasons
`UpupValidationError.reason` is typed as `RestrictionFailedReason` — the subset
of codes a file-level check can produce, plus one string that is not part of the
code enum:
```typescript
type RestrictionFailedReason =
| UpupErrorCode.TYPE_MISMATCH
| UpupErrorCode.FILE_TOO_LARGE
| UpupErrorCode.FILE_TOO_SMALL
| UpupErrorCode.LIMIT_EXCEEDED
| UpupErrorCode.TOTAL_SIZE_EXCEEDED
| UpupErrorCode.DUPLICATE
| 'BEFORE_FILE_ADDED_REJECTED'
```
The per-file checks run in a fixed order — type, then maximum size, then minimum
size — so the first violation reported for a file is the most specific one.
`LIMIT_EXCEEDED` and `TOTAL_SIZE_EXCEEDED` are batch-level instead: they are
raised once for the whole incoming batch, with `file` set to the first file in
it rather than to a single offender.
`'BEFORE_FILE_ADDED_REJECTED'` is the reason reserved for a host application
vetoing a file before it enters the selection. It is a bare string, not a
`UpupErrorCode` member, so compare it literally; like `DUPLICATE`, no code path
emits it today.
## Errors from your own endpoints
When you host the presign or token endpoint yourself, `uploadErrorFromResponse`
is what turns a failed HTTP response into a typed, code-carrying error. Four of
the five upload strategies route their failures through it — direct PUT,
multipart, server credentials, and server-side drive transfer — so a machine code
your endpoint returns arrives intact on `error.code`.
The exception is the tus strategy, which rejects with whatever error
`tus-js-client` produced, unwrapped. Those errors are not `UpupError`s and carry
no `code`, so a handler that reads `error.code` will come up empty on tus
uploads.
```typescript
import { uploadErrorFromResponse } from '@upupjs/core'
const err = uploadErrorFromResponse({
status: response.status,
statusText: response.statusText,
body: await response.text(),
kind: 'storage', // 'storage' | 'auth' | 'network'
operation: 'presign', // required when kind is 'storage'
provider: 'S3', // defaults to 'server'
})
```
`kind` picks the class: `'auth'` builds an `UpupAuthError`, `'storage'` an
`UpupStorageError`, and anything else an `UpupNetworkError`. The originating
HTTP status is always copied onto `error.status`.
### How the body is parsed
`parseErrorBody` is the exported helper behind it, and it tries three shapes in
order:
1. **JSON** — a `code` field and an `error` (or `message`) field. This is the
convention `@upupjs/server` emits, so a server-mode failure reaches the
client with the same machine code the server logged.
2. **S3-style XML** — `` and `` inside an `` envelope.
This is how S3 and S3-compatible providers report a rejected direct PUT, so
provider codes like `SignatureDoesNotMatch` or `EntityTooLarge` survive.
3. **Plain text** — the first 200 characters of the body become the message,
with no code.
If the body yields no code, `error.code` falls back to the class default and the
message falls back to `""`. To make your own endpoint's
failures legible on the client, return JSON with a `code` field:
```typescript
return Response.json(
{ error: 'Bucket is read-only', code: 'STORAGE_ERROR' },
{ status: 500 },
)
```
Anything you put in `code` is preserved verbatim — it does not have to be an
`UpupErrorCode` member.
## Turning codes into user-facing text
`errorCodeToMessageKey(code)` in `packages/core/src/i18n/error-code-map.ts` maps
a machine code to a key in the localized `ErrorMessages` catalog, and it is the
one place that mapping lives. Note that it returns a **message key**, not a code
— the two namespaces look similar and are easy to conflate.
Coverage is partial. Eight of the 24 `UpupErrorCode` members have a dedicated
mapping: `PRESIGN_FAILED`, `STORAGE_ERROR`, `BAD_REQUEST`, `AUTH_PROVIDER_ERROR`,
`AUTH_EXPIRED`, `AUTH_DENIED`, `CORS_ERROR`, and `QUOTA_EXCEEDED`. The map also
handles values that are not enum members at all: `@upupjs/server`'s upload-token
codes (`expired`, `bad_signature`, `malformed`), S3 provider codes
(`AccessDenied`, `SignatureDoesNotMatch`, `EntityTooLarge`, `NoSuchBucket`,
`InvalidBucketName`), and the drive controller's `UNAUTHENTICATED`.
Everything else — the other 16 enum members included — falls through to the
`uploadFailedWithCode` message, and **that message interpolates the raw code**
(`Upload failed with error code: {code}`). So an unmapped code _is_ shown to the
end user. Only a completely absent code is code-free, falling back to the plain
`uploadFailed` message. If you surface codes that matter to your users, either
map them to your own copy or override `uploadFailedWithCode` for your locale.
See [Localization](/docs/localization/) for overriding those strings per locale.
---
# Events
https://useupup.com/docs/api-reference/events/
Every upload, file, drag, pipeline, and UI interaction in upup flows through one
typed event bus on `UpupCore`. Each framework's callback props are a convenience
layer over that bus, and a lossy one — the bus carries events no prop exposes,
and a few props fire on paths that emit no event at all. When in doubt, listen
to the event.
## Listening
With the visual uploader, use the callback props:
```tsx
console.log('started')}
onFileUploadProgress={(file, p) => console.log(file.name, p.percentage)}
onUploadComplete={files => console.log(files.map(f => f.key))}
onError={message => console.error(message)}
/>
```
Headless, subscribe to the bus directly. `on()` returns an unsubscribe
function:
```ts
import { UpupCore } from '@upupjs/core'
const core = new UpupCore({ uploadEndpoint: '/api/upload-token' })
const off = core.on('upload-progress', ({ fileId, loaded, total }) => {
console.log(fileId, Math.round((loaded / total) * 100))
})
core.on('upload-error', ({ error, file }) => {
console.error(file?.name ?? 'run', error.message)
})
// Later:
off()
core.destroy()
```
The React hook `useUpupUpload` exposes the same `on(event, handler)`, and the
uploader ref exposes the underlying `core`. See
[Headless Usage](/docs/guides/headless/) for both.
Bare event names are the typed catalog below and nothing else — an unknown bare
name is a compile error at the `on` / `emit` site.
> **There is exactly one upload-failure event: `upload-error`.** A bare
> `'error'` event does not exist. Failed `resume()` calls route through
> `upload-error`, and so does every pipeline step failure except HEIC
> conversion, which downgrades to a `pipeline-error` diagnostic.
> Cloud-drive plugins emit **namespaced** `:` names
> (e.g. `google-drive:files-loaded`, `one-drive:session-expired`) which pass
> through the bus untyped and are not part of this catalog — see
> [Credentials & Cloud Drives](/docs/credentials-configuration/).
Events whose payload is listed as `{}` carry an empty object; the payload
argument exists but has no fields.
## Upload lifecycle
A run drives one status projection, and the events below are emitted as it moves
between states:
| Event | Payload | Fires when |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `state-change` | `{ status?: UploadStatus; error?: Error; files?: Map; progress?: { totalFiles: number; completedFiles: number; percentage: number } }` | Any observable state moves — the catch-all every framework binding re-renders from. Emitted alongside most other events, carrying only the slice that changed. |
| `upload-start` | `{ retry?: boolean; fileId?: string }` | A run begins. `retry: true` (with the optional `fileId`) marks a retry run rather than a fresh one. |
| `file-upload-start` | `{ file: UploadFile }` | An individual file starts transferring. |
| `upload-progress` | `{ fileId: string; loaded: number; total: number }` | Byte progress advances for one file. |
| `upload-success` | `{ file: UploadFile; result: UploadResult }` | One file finishes successfully; `result.key` is its storage key. |
| `upload-error` | `{ error: Error; file?: UploadFile }` | A file fails (payload carries `file`) **or** the run ends terminally (no `file`). The only failure event — but see the double-emit warning below. |
| `upload-all-complete` | `UploadFile[]` | Every file in the run has a storage key — the batch is done. |
| `upload-pause` | `{}` | `core.pause()` pauses an in-flight run. |
| `upload-resume` | `{}` | `core.resume()` restarts a paused run. |
| `upload-cancel` | `{}` | `core.cancel()` aborts the run; pending files return to `IDLE`. |
| `retry` | `{ fileId?: string }` | `core.retry(fileId?)` is called, before the retry run starts. Omitting `fileId` retries every unfinished or failed file. |
| `destroyed` | `{}` | `core.destroy()` runs. Terminal — afterwards `upload`/`resume`/`retry`/`addFiles`/`setFiles` throw. |
> **One failed file emits `upload-error` twice.** The per-file emission fires
> first (with `file`), then the batch rejects and the terminal handler emits a
> second time (without `file`, carrying an aggregate
> `UpupUploadBatchError`). Anything bound to `upload-error` — including the
> `onError` prop — therefore runs **twice** for a single failure. Deduplicate on
> `file` being present if you only want the per-file signal, and treat the
> `file`-less emission as "the run is over".
> **Vanilla only:** `@upupjs/vanilla` also emits `upload-error` when a link
> import fails, so a URL-fetch failure reaches the same channel as an upload
> failure. React's URL path calls `onError` without emitting the event. Don't
> assume `upload-error` implies a transfer was attempted.
## File operations
| Event | Payload | Fires when |
| -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `files-added` | `UploadFile[]` | One or more files pass validation and enter core state (only fires when at least one was accepted). |
| `file-removed` | `UploadFile` | A single file is removed via `removeFile(id)`. |
| `file-rejected` | `{ count: number }` | Some of an `addFiles` batch were filtered out — `count` is how many. The accepted remainder still lands. |
| `file-replaced` | `{ file: UploadFile }` | A file's content is swapped in place, keeping its id (the image editor's save path). |
| `files-cleared` | `{}` | `removeAll()` empties the list and clears any crash-recovery snapshot. |
| `files-set` | `{ count: number }` | `setFiles()` replaces the whole selection; `count` is the resulting size. |
| `files-reordered` | `{ fileIds: string[] }` | `reorderFiles()` changes the display order; `fileIds` is the new order. |
| `restriction-failed` | `{ error: unknown }` | `addFiles()` throws instead of filtering — a hard validation failure (type, size, count). The error is rethrown to the caller. Nothing in upup subscribes to this event; the built-in UI reacts by catching the rethrow instead. |
## Plugins and options
| Event | Payload | Fires when |
| ------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- |
| `plugin-registered` | `{ name: string }` | `core.use(plugin)` registers a plugin, after its `init(emitter)` hook runs. |
| `options-updated` | `{ partial: Partial }` | `updateOptions()` merges new options — how framework bindings forward changed props to core. |
## Recovery
| Event | Payload | Fires when |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `snapshot-restored` | `{ count: number; status: UploadStatus }` | `core.restore(snapshot)` rehydrates files and status from a snapshot. |
| `crash-recovery-restored` | `{}` | A persisted IndexedDB session is recovered after a refresh or crash. Files that were mid-flight come back as `PAUSED`. Emitted after the `snapshot-restored` it triggers. |
## UI flow
| Event | Payload | Fires when |
| -------------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| `done` | `{}` | The user dismisses a finished run (the Done action), after `onDoneClicked` and before the reset. |
| `state-reset` | `{}` | The uploader returns to its empty initial state. |
| `auto-upload` | `{ count: number }` | `autoUpload` is on and newly added files trigger an upload automatically; `count` is how many. |
| `connection-online` | `{}` | The browser regains network connectivity. |
| `connection-offline` | `{}` | The browser loses network connectivity. |
## Image editor
React and Preact only — the other frameworks intentionally stub the editor.
| Event | Payload | Fires when |
| --------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------- |
| `image-editor-open` | `{ file: UploadFile }` | The editor opens for a file, whether by user action or `imageEditor.autoOpen`. |
| `image-editor-cancel` | `{ file: UploadFile }` | The editor closes without saving. If more files are queued, the next one opens. |
| `image-editor-save` | `{ file: UploadFile; original: UploadFile }` | An edit is saved. `file` is the new content, `original` is what it replaced (same id). |
## Drag, drop, and paste
| Event | Payload | Fires when |
| --------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `drag-over` | `{}` | A drag hovers the dropzone. Bound straight to the DOM `dragover` event with no enter-transition guard, so it fires **repeatedly** throughout the hover — debounce it, don't treat it as an enter signal. |
| `drag-leave` | `{}` | A drag leaves the dropzone without dropping. Leaves into a child element are filtered out, so this fires once per real exit. |
| `drop` | `{ files: File[] }` | Files are dropped and accepted. Not emitted when a folder drop is blocked and nothing else came with it. |
| `folder-drop-blocked` | `{ acceptedFiles: number }` | A dropped folder was ignored because `folderUpload.allowDrop` is off. `acceptedFiles` counts the loose files that still went through. |
| `paste` | `{ files: File[] }` | Files are pasted with `enablePaste` on. A pasted file is renamed to `pasted-.` only when the clipboard gave it no name or the literal name `image.png`; any other name is kept as-is. |
## Pipeline diagnostics
The processing pipeline (hash, HEIC, EXIF, thumbnail, compress) reports through
these as it works through each file.
| Event | Payload | Fires when |
| ------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pipeline-start` | `{ fileId: string; steps: string[] }` | Processing begins for a file. `steps` lists every **configured** step — a step whose `shouldProcess` returns false is still listed here but never runs, so it emits no `pipeline-step`. |
| `pipeline-step` | `{ fileId: string; step: string }` | A step **finished**. It is emitted after the step resolves, not before it starts, so a step that never completes produces no event. |
| `pipeline-complete` | `{ fileId: string }` | All steps finished for that file. |
| `pipeline-error` | `{ scope: string; name: string; message: string }` | HEIC conversion failed. This is the only source — the pipeline engine has no error handling of its own. |
> **`pipeline-error` is not the general step-failure event.** Only the HEIC step
> catches its own errors and reports them here, downgrading a failed conversion
> to a diagnostic while the run continues. Every **other** step failure
> propagates out of the pipeline uncaught and fails the whole run through
> `upload-error`. So the absence of `pipeline-error` says nothing about whether
> processing succeeded.
## UI telemetry
Emitted by every framework's UI layer with identical payloads (React is the
payload canon). Useful for analytics on how people drive the picker.
| Event | Payload | Fires when |
| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source-click` | `{ sourceId: FileSource }` | A source tile is clicked in the source selector. |
| `source-view-cancel` | `{ sourceId: FileSource \| undefined }` | The user backs out of an open source view. |
| `browse-files` | `{}` | The native file picker is opened for a normal file browse. |
| `folder-select` | `{ count: number }` | A folder is chosen. `count` is the file count via the File System Access API, or `0` on the `webkitdirectory` input fallback where the count isn't known yet. |
| `url-submit` | `{ url: string }` | The link-import form is submitted. |
| `url-fetch` | `{ file: File }` | A link import finishes downloading and produced a file. |
| `url-fetch-cancel` | `{ url: string }` | An in-flight link import is aborted. |
| `camera-capture` | `{ dataUrl: string }` | A camera photo is captured, before the user confirms it. |
| `camera-confirm` | `{ file: File }` | The captured photo is confirmed and added. |
| `file-preview-open` | `{ fileId: string; fileName: string }` | A file preview overlay opens. |
| `file-preview-close` | `{ fileId: string; fileName: string }` | A file preview overlay closes. |
## React callback props
`` accepts these handlers. They come from the shared
`UploaderBaseProps` type, so React, Vue, Svelte, Angular, and Preact all take
the same set (each with its own binding syntax). Most are driven by a core
event — subscribe to the event instead when you need the full payload, ordering
guarantees, or a listener outside the component.
> **Vanilla is a different surface.** `@upupjs/vanilla`'s `createUploader`
> takes `CreateUploaderOptions`, not `UploaderBaseProps`, and accepts only
> eleven callbacks: `onFileAdded`, `onFileRemoved`, `onUploadProgress`,
> `onUploadComplete`, `onDoneClicked`, `onIntegrationClick`, `onFilesDragOver`,
> `onFilesDragLeave`, `onFilesDrop`, `onWarn`, `onError`. Note `onFileAdded`
> rather than `onFilesSelected`, and a single `onUploadProgress` rather than the
> per-file/aggregate pair. `onStatusChange`, `onPrepareFiles`,
> `onRestrictionFailed`, `onFileTypeMismatch`, and the per-file upload callbacks
> have no vanilla equivalent — subscribe to the events directly there.
| Prop | Signature | Driven by |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `onFilesSelected` | `(files: UploadFile[]) => void` | `files-added` |
| `onDoneClicked` | `() => void` | `done` (called just before the event) |
| `onPrepareFiles` | `(files: UploadFile[]) => Promise` | No event — an async gate awaited at the start of `startUpload()`; return the list to upload |
| `onFileClick` | `(file: UploadFile) => void` | No event — a click on a file row in the list |
| `onIntegrationClick` | `(integrationType: string) => void` | `source-click` (called just before the event) |
| `onUploadStart` | `() => void` | `upload-start` |
| `onFileUploadStart` | `(file: UploadFile) => void` | `file-upload-start` |
| `onFileUploadProgress` | `(file: UploadFile, progress: { loaded: number; total: number; percentage: number }) => void` | `upload-progress` (per file; `percentage` is computed for you) |
| `onFilesUploadProgress` | `(completedFiles: number, totalFiles: number) => void` | `upload-progress` (batch aggregate) |
| `onFileUploadComplete` | `(file: UploadFile, key: string) => void` | `upload-success` |
| `onFilesUploadComplete` | `(files: UploadFile[]) => void` | `upload-all-complete` |
| `onUploadComplete` | `(files: UploadFile[]) => void` | `upload-all-complete` (same event as above; both fire) |
| `onStatusChange` | `(status: string) => void` | `state-change` — the projected status, lowercased and deduplicated so it fires once per real transition |
| `onFileRemoved` | `(file: UploadFile) => void` | `file-removed` |
| `onFilesDragOver` | `(files: File[]) => void` | `drag-over` — fires repeatedly during the hover, and `files` is effectively always `[]` (see below) |
| `onFilesDragLeave` | `(files: File[]) => void` | `drag-leave` — `files` is effectively always `[]` (see below) |
| `onFilesDrop` | `(files: File[]) => void` | `drop` (called just before the event) |
| `onFileTypeMismatch` | `(file: File, acceptedTypes: string) => void` | No event — the built-in UI's rejection path only (see below) |
| `onRestrictionFailed` | `(file: File, reason: 'TYPE_MISMATCH' \| 'FILE_TOO_LARGE' \| 'FILE_TOO_SMALL' \| 'LIMIT_EXCEEDED') => void` | No event — the built-in UI's rejection path only (see below) |
| `onBeforeFileAdded` | `(file: File) => boolean \| File \| undefined \| Promise` | No event — an async filter run per file before it is added. Return `false` to reject, a `File` to substitute, `true`/`undefined` to accept |
| `onError` | `(errorMessage: string) => void` | `upload-error`, plus validation failures from `addFiles()`; delivers a message string |
| `onWarn` | `(warningMessage: string) => void` | No event of its own — non-fatal notices such as an ignored folder drop (which also emits `folder-drop-blocked`) |
| `onFileProcessed` | `(file: UploadFile, data: Record) => void` | No core event — the server's SSE message on `processingEndpoint` (opened after the whole batch, see below) |
`onFilesUploadComplete` and `onUploadComplete` are both invoked from
`upload-all-complete` — pick one.
### Caveats worth knowing
**The rejection callbacks are UI-path only, and the reason is guessed from text.**
`onFileTypeMismatch` and `onRestrictionFailed` are _not_ wired to
`restriction-failed`. The built-in UI wraps its `addFiles` call in a try/catch
and classifies the thrown error by substring-matching its **message** —
`'type'`, `'limit'`, `'below'`, `'size'` — to pick the reason code. Two
consequences: calling `core.addFiles()` yourself emits `restriction-failed` but
invokes neither callback, and the reason code is a heuristic over
human-readable text rather than a structured field. For robust handling,
subscribe to `restriction-failed` and inspect the error yourself. Both callbacks
fire for a type mismatch; `onRestrictionFailed` alone covers the size and count
cases.
**Drag callbacks never receive files.** `onFilesDragOver` and `onFilesDragLeave`
are handed `e.dataTransfer.files`, which browsers keep empty during a drag
(the DataTransfer is in protected mode until the drop completes). Expect `[]`
and use these purely as hover signals — real files arrive only in `onFilesDrop`.
**SSE processing is per batch, not per file.** Despite its name and per-file
signature, `onFileProcessed` is driven by a connection opened once the _entire_
batch finishes: the handler folds into `onFilesUploadComplete` and then opens one
`EventSource` per completed file. No connection opens after each individual
upload, so with a long batch the first file's server-side processing isn't
watched until the last file lands.
## Next steps
- [Event Handlers](/docs/api-reference/upupuploader/event-handlers/) — the short
version of the callback props.
- [Headless Usage](/docs/guides/headless/) — drive `UpupCore` and its bus with no
upup UI at all.
- [Error Handling](/docs/error-handling/) — the `UpupError` taxonomy behind
`upload-error`.
---
# S3 Presign Responses
https://useupup.com/docs/api-reference/s3-generate-presigned-url/
For client uploads, return a presigned object URL and any headers that were part
of the signature.
```ts
type PresignedUrlResponse = {
key: string
uploadUrl: string
uploadHeaders?: Record
publicUrl?: string
downloadUrl?: string
expiresIn: number
}
```
Do not sign browser-forbidden headers such as `Content-Length` for direct
browser `PUT` requests. If the content type is signed, return it in
`uploadHeaders` so upup can send the exact same value.
```ts
return Response.json({
key,
uploadUrl,
uploadHeaders: {
'Content-Type': body.type || 'application/octet-stream',
},
downloadUrl,
expiresIn: 3600,
})
```
Use `@upupjs/server` when you want the server package to host these routes.
---
# Server HTTP API
https://useupup.com/docs/api-reference/server-http/
`createUpupHandler(config)` returns one `(req: Request) => Promise`
function that serves every route below. This page is the wire-level contract:
what each route accepts, what it returns, and which status codes it can produce.
Reach for it when you are writing a non-upup client, proxying the handler, or
reading a failing request in your logs. For wiring the handler into a framework,
see [Server Mode — Setup](/docs/guides/server-mode-setup/); for why the 403s
exist, see [Server Auth & Trust Model](/docs/guides/server-auth/).
## Routing model
Routes are matched on the **path suffix**, so the handler works at whatever
prefix you mount it (`/api/upup`, `/upload`, the root). Every example below is
written relative to that mount point.
Trailing slashes are tolerated: the handler strips them before matching, so a
framework that normalizes URLs (Next.js `trailingSlash: true` 308s a
`POST /presign` to `/presign/` with method and body intact) still hits the
route instead of falling through to 404.
Two routes run **before** the global `auth` gate — `OPTIONS` preflight and
`GET /health` — so browsers can preflight and uptime probes can poll without
credentials. Everything else passes through `config.auth` first, when you have
configured it.
## Route table
| Method | Path | Auth | Success |
| --------- | --------------------------- | ----------------------------------------------- | -------------------- |
| `OPTIONS` | any path | none (before the gate) | `204`, no body |
| `GET` | `/health` | none (before the gate) | `200` always |
| `POST` | `/presign` | `auth` / `getUserId` / `allowAnonymousUploads` | `200` presigned PUT |
| `POST` | `/multipart/init` | `auth` / `getUserId` / `allowAnonymousUploads` | `200` + upload token |
| `POST` | `/multipart/sign-part` | upload token (owner-bound when `getUserId` set) | `200` presigned part |
| `POST` | `/multipart/complete` | upload token (owner-bound when `getUserId` set) | `200` final object |
| `POST` | `/multipart/abort` | upload token (owner-bound when `getUserId` set) | `200` `{ ok: true }` |
| `POST` | `/multipart/resume` | upload token (owner-bound; expiry relaxed) | `200` parts + token |
| `GET` | `/auth/:provider` | resolved user id | `302` to provider |
| `GET` | `/auth/:provider/cb` | OAuth `state` (single-use, 10 min) | `200` HTML |
| `GET` | `/files/:provider` | resolved user id + stored drive tokens | `200` file list |
| `POST` | `/files/:provider/transfer` | resolved user id + stored drive tokens | `200` stored object |
`:provider` is one of `google-drive`, `one-drive`, `dropbox`, `box` — the
kebab-case wire form. Anything else returns `400`.
Any method/path pair not in the table falls through to
`404 {"error":"Not found"}` — including a `GET /presign` or a
`POST /files/:provider` without the `/transfer` suffix.
`POST /multipart/resume` is the one route you can switch off: setting
`multipartResumeWindowSeconds: 0` stops it from being routed at all, so it 404s
like any unknown path. That is deliberate — a server with resume disabled is
indistinguishable from one too old to have the route, and upup clients treat
both the same way.
## Response conventions
Every response the handler produces — success, error, redirect, preflight —
carries an **`x-upup-request-id`** header holding a fresh UUID for that request.
The same id appears in the `onError` log record, so a client-side failure
correlates to exactly one server log line. There is no route that skips it.
CORS headers are added to every response too, when `config.cors` is set and the
request's `Origin` matches `allowedOrigins` (or the list contains `*`). The
matched origin is reflected rather than a literal `*`; a bare `*` goes out only
to origin-less, non-browser callers.
`Access-Control-Allow-Credentials: true` is sent **only** on a concrete origin
match — a wildcard-only allowlist gets public, non-credentialed CORS.
Failure bodies are uniform:
```json
{ "error": "human-readable message", "code": "MACHINE_CODE" }
```
The `code` field is present on coded failures (the `UpupErrorCode` values plus
the upload-token codes); a few plain validation rejections carry `error` alone.
The real cause — stack, provider response — goes to your
[`onError` seam](/docs/guides/error-monitoring/), never to the client.
An unhandled throw anywhere in routing is caught by a single transport safety
net that returns a logged, CORS-headered
`500 {"error":"Internal error","code":"STORAGE_ERROR"}`. Adapters do not need a
catch of their own.
## `OPTIONS` — preflight
Returns `204` with no body, the CORS headers, and `x-upup-request-id`. Matched
before the auth gate and before any path matching, so it answers for every path
the handler is mounted under.
## `GET /health`
Answers "is this instance configured, and can it reach storage?" in one
unauthenticated request, without performing an upload. It **always returns
`200`** — a liveness probe should not restart a container because S3 blipped —
so read the `status` field, not the HTTP code.
```json
{
"status": "ok",
"checks": { "config": "ok", "storage": "ok" },
"summary": {
"storageType": "aws",
"anonymousUploads": false,
"anonymousDrives": false,
"driveProviders": 2,
"uploadTokenTtlSeconds": 3600
}
}
```
| Field | Values |
| ------------------------------- | ------------------------------------------------------------------------ |
| `status` | `ok` when both checks pass, otherwise `degraded` |
| `checks.config` | `ok` / `incomplete` — bucket, region, and a ≥ 16-char secret are all set |
| `checks.storage` | `ok` / `error` — a `HeadBucket` probe, cached for 30 s |
| `summary.storageType` | the configured `storage.type` label |
| `summary.anonymousUploads` | whether `allowAnonymousUploads` is on |
| `summary.anonymousDrives` | whether `allowAnonymous` (drive/tokenStore scoping) is on |
| `summary.driveProviders` | count of configured OAuth providers |
| `summary.uploadTokenTtlSeconds` | lifetime of an issued multipart upload token |
No secret **value** is ever returned. Setting
`health: { exposeSecretFingerprint: true }` adds one extra field:
```json
{ "uploadTokenFingerprint": "9f2c41ab" }
```
That is the first 8 hex characters of `SHA-256(uploadTokenSecret)` — enough to
confirm every instance in a fleet shares the same secret (a rolling redeploy
that half-rotated it shows two different fingerprints), and not reversible to
the secret itself. It is off by default.
## `POST /presign`
Issues a presigned `PUT` so the browser uploads bytes straight to storage. The
server chooses the object key; the client never proposes one.
**Auth.** This is a capability-granting route, so it is closed by default: with
none of `auth`, `getUserId`, or `allowAnonymousUploads` configured it returns
`403` `AUTH_REQUIRED`. That is the intended posture — configure one of the three
rather than working around it.
**Request**
```ts
type FileMetadata = {
name: string // non-empty
type: string // MIME type; may be empty, but then it must pass allowedTypes
size: number // bytes, finite, >= 0
}
```
**Response `200`** — the `PresignedUrlResponse` shape from `@upupjs/core`:
```ts
type PresignedUrlResponse = {
key: string
publicUrl?: string
downloadUrl?: string
uploadUrl: string
uploadHeaders?: Record
expiresIn: number
}
```
This handler returns `key`, `uploadUrl`, `downloadUrl`, `uploadHeaders`, and
`expiresIn: 3600`. The PUT signature covers both `content-type` and
`content-length`, so a body larger than the approved size fails at S3 rather
than silently landing. `downloadUrl` is a separately signed `GET` valid for
three days.
**Status codes**
| Status | Body | Cause |
| ------ | ------------------------------------------------------------- | --------------------------------------------- |
| `400` | `{ "error": "Invalid JSON body", "code": "BAD_REQUEST" }` | unparseable body |
| `400` | `{ "error": "Invalid file metadata", "code": "BAD_REQUEST" }` | missing/wrong-typed `name`, `type`, or `size` |
| `401` | `{ "error": "Unauthorized" }` | your `auth` gate returned `false` |
| `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` |
| `403` | `{ "error": "…", "code": "AUTH_REQUIRED" }` | no auth path configured |
| `403` | `{ "error": "Upload rejected" }` | your `hooks.onBeforeUpload` returned `false` |
| `413` | `{ "error": "File too large" }` | `size` exceeds `maxFileSize` |
| `415` | `{ "error": "File type not allowed" }` | `type` does not match `allowedTypes` |
| `500` | `{ "error": "Presign failed", "code": "PRESIGN_FAILED" }` | the storage call threw |
Checks run in that order: JSON parse, metadata shape, size, type,
`onBeforeUpload`, then identity resolution. So an oversized file is rejected
with `413` before `getUserId` is consulted.
The `AUTH_REQUIRED` body carries the full remedy as its message:
`Anonymous uploads are disabled. Set allowAnonymousUploads:true, or configure auth/getUserId.`
## `POST /multipart/init`
Starts an S3 multipart session and returns the signed upload token that
authorizes every later step. Same auth gate, same metadata validation, and the
same `400` / `401` / `403` / `413` / `415` table as `/presign`.
**Request**
```ts
{
name: string
type: string
size: number
chunkSizeBytes?: number // requested part size; floors and bumps applied
}
```
**Response `200`** — `MultipartInitResponse` plus the token:
```ts
type MultipartInitResponse = {
key: string
uploadId: string
partSize: number
expiresIn: number
/** Opaque server-issued token bound to {key, uploadId, size, expiry}. */
token?: string
}
```
`token` is always present on responses from this handler. Treat it as opaque:
it is a base64url payload plus an HMAC-SHA-256 signature, and it binds the
object key, the S3 `uploadId`, the resolved user id (`null` when anonymous), the
allowed total-size envelope, an expiry one hour out, and the issue time. Send it
back verbatim on every continuation request.
The issue time is what [`/multipart/resume`](#post-multipartresume) anchors its
window on, and it is carried forward unchanged when that route re-issues a
token. Tokens minted before resume shipped carry no issue time; the resume route
derives it from the expiry, since `init` is the only issuer and has always used
the same TTL.
`partSize` is the server's decision, not your request — see
[Policy and limits](#policy-and-limits).
`500` here is `{ "error": "Multipart init failed", "code": "STORAGE_ERROR" }`.
## `POST /multipart/sign-part`
Presigns one part's `PUT`. The token is the credential — the client never
re-asserts the key or `uploadId`, because the server re-derives both from the
verified token.
**Request**
```ts
{
token: string
partNumber: number
}
```
**Response `200`** — `MultipartSignPartResponse`:
```ts
type MultipartSignPartResponse = {
uploadUrl: string
uploadHeaders?: Record
expiresIn: number
}
```
This handler returns `uploadUrl` and `expiresIn: 3600`.
**Token failures.** A missing, tampered, or stale token returns `403` with the
specific reason, so you can tell a bug from an expiry:
```json
{ "error": "Invalid upload token", "code": "bad_signature" }
```
`code` is one of `malformed`, `bad_signature`, or `expired`. The signature is
verified before any payload byte is trusted, and compared in constant time.
`expired` is recoverable rather than fatal: present the expired token to
[`/multipart/resume`](#post-multipartresume) and retry the call with the fresh
one it returns. upup's own client does this automatically, once per expiry,
shared across its concurrent part uploads.
**Owner binding.** When `getUserId` is configured, the caller's current identity
is re-checked against the `uid` the token was issued to. A token that leaked to a
different authenticated user cannot be replayed:
```json
{
"error": "Upload token does not belong to the current user",
"code": "AUTH_DENIED"
}
```
Without `getUserId` there was no identity to bind at `init`, `uid` is `null`,
and the check is skipped — possession of the token is then the model by design.
`500` is `{ "error": "Multipart sign failed", "code": "STORAGE_ERROR" }`.
## `POST /multipart/complete`
Finalizes the object. Verifies the token and the owner binding exactly as
`sign-part` does, then enforces the signed size envelope before committing.
**Request**
```ts
{
token: string
parts: Array<{ partNumber: number; eTag: string }>
}
```
**Response `200`** — `MultipartCompleteResponse`:
```ts
type MultipartCompleteResponse = {
key: string
publicUrl?: string
downloadUrl?: string
etag?: string
}
```
This handler returns `key`, a three-day signed `downloadUrl`, and `etag` when S3
supplied one.
**The envelope check.** Because `sign-part` and the browser's direct PUTs never
re-send a size, the real byte total is only knowable here. The handler sums what
S3 actually received (`ListParts`) and compares it against the `[smin, smax]`
range signed at `init`. Outside that range, the multipart upload is **aborted**
— nothing partial is left in the bucket — and the request gets:
```json
{ "error": "Upload size outside signed envelope" }
```
with status `403`. This closes the "declare one byte at init, stream a gigabyte"
path.
After a successful commit, `hooks.onFileUploaded` and `hooks.onUploadComplete`
run. A hook that throws is logged through `onError` and swallowed: the object is
already durable, so the client still gets its `200` rather than a `500` telling
it to retry an upload that succeeded.
`500` is `{ "error": "Multipart complete failed", "code": "STORAGE_ERROR" }`.
## `POST /multipart/abort`
Cancels the session and asks S3 to discard the uploaded parts. Token
verification and owner binding are identical to `sign-part`.
**Request**
```ts
{
token: string
}
```
**Response `200`** — `MultipartAbortResponse`:
```ts
type MultipartAbortResponse = { ok: true }
```
`500` is `{ "error": "Multipart abort failed", "code": "STORAGE_ERROR" }`.
## `POST /multipart/resume`
Re-attaches to an upload a previous page load left in flight. Answers with the
parts storage already holds — each with its byte size — and a freshly-signed
token. This is what powers
[cross-reload resume](/docs/resumable-uploads/#cross-reload-resume), and it is
also how a client refreshes a token that expired mid-upload.
**Request**
```ts
{
token: string
}
```
The token is the only input. The client never sends the key or the `uploadId`,
and neither comes back — the server re-derives both from the verified token, so
the `uploadId` never leaves the signed payload.
**Response `200`** — `MultipartResumeResponse`:
```ts
type MultipartResumeResponse = {
key: string
/** Fresh token replacing the presented one — same key, uploadId, owner,
* and size envelope; new expiry. */
token: string
/** Parts the provider already holds; every entry carries `size`. */
parts: Array<{ partNumber: number; eTag: string; size?: number }>
}
```
Send the returned `token` on every subsequent `sign-part` / `complete` /
`abort` call — the presented one is not invalidated, but it is the stale one.
**Expiry, relaxed and then re-bounded.** This is the only route that accepts a
token whose `exp` has passed, because handing an expired token back as a fresh
one is the whole point: an upload can easily outlive the one-hour TTL. A
tighter bound replaces it — the **resume window**, measured from the original
`/multipart/init` and carried forward unchanged on every re-issue, so a chain of
resumes can never extend it. Signature and shape verification are identical to
every other route.
| Config | Default | Effect |
| --------------------------------- | ------- | -------------------------------------------------------- |
| `multipartResumeWindowSeconds` | `86400` | Seconds after the original `init` that resume is allowed |
| `multipartResumeWindowSeconds: 0` | — | Route is not registered; requests 404 |
A negative or fractional value throws `UpupConfigError` at construction rather
than being coerced.
**Owner binding** is enforced exactly as on `sign-part`: with `getUserId`
configured the caller's identity is re-checked against the token's `uid`, and a
mismatch returns `403 AUTH_DENIED`. Without `getUserId`, possession of the token
is the model, as everywhere else.
**Status codes**
| Status | Body | Cause |
| ------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `400` | `{ "error": "Invalid JSON body", "code": "BAD_REQUEST" }` | unparseable body |
| `403` | `{ "error": "Invalid upload token", "code": "malformed" \| "bad_signature" }` | the token is not a token, or the signature does not verify |
| `403` | `{ "error": "Upload resume window has expired", "code": "expired" }` | more than `multipartResumeWindowSeconds` since the original `init` |
| `403` | `{ "error": "…", "code": "AUTH_DENIED" }` | owner mismatch |
| `404` | `{ "error": "Multipart upload no longer exists", "code": "NOT_FOUND" }` | the provider has no such upload — completed, aborted, or reaped by a lifecycle rule |
| `500` | `{ "error": "Multipart resume failed", "code": "STORAGE_ERROR" }` | the storage call threw for any other reason |
A dead upload is deliberately a `404`, never a `500`: upup clients drop the
session and start fresh on a `4xx`, whereas a `5xx` reads as "try again later"
and would retry an upload that can never come back. The `NOT_FOUND` check
matches the provider's `NoSuchUpload` error specifically, not a bare 404 status
— a missing _bucket_ also 404s, and that is a misconfiguration, not a stale
session.
A leaked token stays usable for the resume window instead of one hour. What
it can do with that window is unchanged and narrow: continue the **same**
upload, to the **same** key, inside the **same** signed size envelope, still
owner-bound whenever `getUserId` is configured. It cannot start an upload,
retarget one, or widen the byte envelope. Shorten
`multipartResumeWindowSeconds`, or set it to `0`, if that trade is not one
you want.
## `GET /auth/:provider`
Begins the cloud-drive OAuth flow. Requires a `tokenStore` and a resolvable user
id — drive tokens are stored per user, which is why `providers` or `tokenStore`
without `getUserId` is refused at construction unless you set
`allowAnonymous: true`.
**Query parameters**
| Name | Required | Meaning |
| ---------- | -------- | ------------------------------------------------------------------------ |
| `returnTo` | no | Where to send the popup afterwards. Validated; see the callback section. |
**Response `302`** to the provider's authorization URL, carrying a 32-byte
random `state` and a `redirect_uri` derived from the request URL — always
`/auth/:provider/cb`, so it is identical on the redirect and the exchange
and providers do not reject it as a mismatch. The `state` is stored for **10
minutes** and is single-use.
| Status | Body | Cause |
| ------ | ------------------------------------------------------- | ---------------------------------------- |
| `400` | `{ "error": "Unknown provider: x" }` | not one of the four wire slugs |
| `400` | `{ "error": "Google Drive not configured" }` | that provider is absent from `providers` |
| `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` |
| `500` | `{ "error": "tokenStore is required for OAuth flows" }` | no `tokenStore` configured |
| `500` | `{ "error": "No OAuth providers configured" }` | no `providers` block at all |
Requested scopes are read-only per provider: Google Drive
`drive.readonly`, OneDrive `Files.Read.All offline_access`, Dropbox
`files.content.read files.metadata.read`, Box `root_readonly`.
## `GET /auth/:provider/cb`
The provider's redirect target. Exchanges the authorization code for tokens,
persists them against the user id captured in the `state`, and renders a small
HTML page that closes the popup.
**Query parameters:** `code`, `state`, or `error` from the provider.
**Response `200`** is `text/html`, not JSON. The page posts
`{ type: 'upup:oauth-success', provider }` to `window.opener`, targeted at each
concrete origin in `cors.allowedOrigins` (falling back to `*` only when no
concrete origin is configured — the payload carries no token), then closes
itself. With no opener it redirects to a validated `returnTo`, or prints a
"you may close this window" line.
`returnTo` is validated fail-closed: it is accepted only when it resolves to the
server's own origin or to a concrete (non-wildcard) entry in
`cors.allowedOrigins`. A `*` in the allowlist does not authorize an arbitrary
redirect target.
Both `/auth` routes sit behind your global `auth` gate. The callback arrives
as a top-level browser navigation from the provider, so it carries cookies
but no `Authorization` header — an `auth` implementation that only reads a
bearer token will reject the callback with `401`. Accept a session cookie
there, or scope your gate to the upload routes.
| Status | Body | Cause |
| ------ | --------------------------------------------------------------------- | ------------------------------------------------- |
| `400` | `{ "error": "OAuth error: access_denied" }` | the provider returned an error |
| `400` | `{ "error": "Missing code or state" }` | truncated callback |
| `400` | `{ "error": "Invalid or expired state" }` | replayed, unknown, or provider-mismatched `state` |
| `400` | `{ "error": "Unknown provider: x" }` | bad slug |
| `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` |
| `502` | `{ "error": "Token exchange failed", "code": "AUTH_PROVIDER_ERROR" }` | the provider rejected the code exchange |
## `GET /files/:provider`
Lists one folder of the connected drive, or searches it. Requires a resolved
user id and stored tokens for that user and provider.
**Query parameters**
| Name | Required | Meaning |
| ---------- | -------- | ---------------------------------------------------------- |
| `folderId` | no | Provider folder id; omitted means that provider's root. |
| `search` | no | Free-text query; when present it replaces folder browsing. |
**Response `200`**
```ts
{
provider: 'google-drive' | 'one-drive' | 'dropbox' | 'box'
files: Array<{
id: string
name: string
size?: number
mimeType?: string
thumbnailUrl?: string
isFolder: boolean
modifiedAt?: string
}>
}
```
**Re-authentication.** When the caller has no stored tokens, when a proactive
refresh fails, or when the provider answers `401`, the route returns **`401`**
with a re-auth signal instead of a generic error:
```json
{ "reauth": true, "provider": "google-drive" }
```
In the last two cases the dead tokens are deleted from the store first, so the
next request starts a clean OAuth flow. Send the user back through
`GET /auth/:provider`. Tokens holding a refresh token are refreshed
automatically 30 seconds before expiry, so a routine expiry never surfaces as
`reauth`.
| Status | Body | Cause |
| ------ | -------------------------------------------------------------- | ----------------------------------- |
| `400` | `{ "error": "Unknown provider: x" }` | bad slug |
| `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` |
| `401` | `{ "reauth": true, "provider": "…" }` | no / expired / revoked drive tokens |
| `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` |
| `500` | `{ "error": "Drive request failed", "code": "STORAGE_ERROR" }` | the drive API failed |
## `POST /files/:provider/transfer`
Streams one drive file **server-side** into your bucket, so the bytes never
touch the browser. Same identity, token-refresh, and re-auth behavior as the
list route.
**Request**
```ts
{
fileId: string // required
fileName?: string // overrides the provider's name
size?: number // client-declared; used only for the fast reject
mimeType?: string // checked against allowedTypes
}
```
**Response `200`**
```ts
{
provider: string
key: string
name: string
size: number // ACTUAL bytes transferred, not the declared size
type: string
url: string // three-day signed download URL
}
```
`hooks.onFileUploaded` runs after the object is durable, with the same
throw-is-logged-and-swallowed treatment as multipart complete.
| Status | Body | Cause |
| ------ | -------------------------------------------------------------- | ---------------------------------------------- |
| `400` | `{ "error": "Invalid JSON body" }` | unparseable body |
| `400` | `{ "error": "Missing fileId" }` | no `fileId` |
| `400` | `{ "error": "Unknown provider: x" }` | bad slug |
| `401` | `{ "error": "Unauthenticated" }` | `getUserId` returned `null` |
| `401` | `{ "reauth": true, "provider": "…" }` | no / expired / revoked drive tokens |
| `413` | `{ "error": "File too large" }` | declared `size` exceeds `maxFileSize` |
| `415` | `{ "error": "File type not allowed" }` | `mimeType` fails `allowedTypes` |
| `500` | `{ "error": "tokenStore is required" }` | no `tokenStore` |
| `500` | `{ "error": "Drive request failed", "code": "STORAGE_ERROR" }` | drive fetch, size cap, or storage write failed |
The `413` above is only a cheap early-out on the client-declared size. The
authoritative cap is enforced against the bytes actually streamed — see below.
## Policy and limits
**`maxFileSize`** (bytes) applies to `/presign`, `/multipart/init`, and
`/files/:provider/transfer`. On the upload routes it rejects the declared size
with `413`. On the transfer route it is additionally enforced against real
egress: the transfer aborts the moment the streamed total crosses the cap, the
S3 multipart upload is aborted, and **nothing is persisted**. Omit the option and
no size limit applies.
**`allowedTypes`** is a list of MIME patterns. An exact match passes; a trailing
`/*` matches the whole type family (`image/*` matches `image/png`). An empty or
absent type does **not** match a non-empty allowlist — a missing MIME type is
rejected on both the upload and the transfer path, never waved through. Omit the
option and every type passes.
**Multipart part size.** The S3 floor is `MIN_PART_SIZE` = 5 MiB, and an upload
may have at most 10,000 parts. `partSize` returned by `/multipart/init` is
therefore `max(chunkSizeBytes ?? 5 MiB, 5 MiB, ceil(size / 10000))` — a large
file automatically gets a larger part size instead of failing on the part-count
ceiling. Requesting a chunk below 5 MiB silently floors to 5 MiB.
**Drive-transfer buffering.** `SINGLE_PUT_MAX_BYTES` is 5 MiB. A drive file of
known size at or under it is buffered once and written with a single `PUT`;
anything larger — or of unknown size (a provider-reported size of `0`) —
streams through bounded 5 MiB multipart parts, so peak server memory is one part
regardless of file size. This bound is deliberately not configurable — memory
safety is not a knob that can be raised away.
**Upload-token TTL.** One hour from `init`, reported on `/health` as
`summary.uploadTokenTtlSeconds`. A multipart session that outlives it refreshes
its token through [`/multipart/resume`](#post-multipartresume) rather than
restarting, up to `multipartResumeWindowSeconds` after the original `init`.
## Adapters
Express, Fastify, Hono, `@upupjs/next` (App and Pages routers), and any custom
Node adapter built on `@upupjs/server/node-bridge` all dispatch into this exact
contract. The adapters only translate between the framework's request/response
objects and the Web `Request`/`Response` the handler speaks — no route, status
code, or body shape differs between them. See
[Server Mode — Setup](/docs/guides/server-mode-setup/) for the per-framework
mounting code, and [Storage Providers](/docs/guides/storage-providers/) for the
`storage` block behind these routes.
---
# Theme Slots
https://useupup.com/docs/api-reference/upupuploader/classnames/
The stable styling API is `theme.slots`. It is keyed by component, then by the slot within it.
```tsx
```
Use `theme.mode` for color scheme:
```tsx
```
Slot contracts are exported from `@upupjs/core/theme`.
## Internals (what actually renders)
Rendered styling comes from `theme.{mode,tokens,slots}` applied through inline
`cn()` class strings inside each component (e.g. `UploaderPanel`). The
`--upup-spacing-*` CSS-variable group is generated by the theme system but is
**not** consumed by any component today — treat it as reserved, not a live
styling knob.
---
# Event Handlers
https://useupup.com/docs/api-reference/upupuploader/event-handlers/
React events mirror core upload state.
```tsx
console.log(files)}
onUploadStart={() => console.log('started')}
onFileUploadComplete={file => console.log(file)}
onUploadComplete={files => console.log(files)}
onError={message => console.error(message)}
/>
```
Common handlers:
| Handler | Called when |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `onFilesSelected` | Files enter core state. |
| `onFileRemoved` | A file is removed. |
| `onUploadStart` | Upload begins. |
| `onFileUploadProgress` | Byte progress changes for one file. |
| `onFilesUploadProgress` | Aggregate byte progress across the batch. |
| `onFileUploadComplete` | One file succeeds. |
| `onUploadComplete` | All files succeed. |
| `onError` | A validation, config, network, or provider failure occurs; delivers a message string. |
File objects use the `UploadFile` contract from `@upupjs/core/contracts`.
---
# Icon Prop
https://useupup.com/docs/api-reference/upupuploader/icon-prop/
The icon prop is an object used to configure what icons are shown on the UpupUploader client component. It is optional.
Each icon element must be a valid React element that accepts an optional `className` prop for styling.
Recommended icon library suites include [React
Icons](https://react-icons.github.io/react-icons) and [Lucide
Icons](https://lucide.dev/).
| Key | Example | Type | Default value |
| --------------------------------------------- | -------------------------------------------- | ---------------------------- | ---------------- |
| [CameraCaptureIcon](#cameracaptureicon) | `icons={{CameraCaptureIcon: FaCamera}}` | `FC<{ className?: string }>` | `TbCapture` |
| [CameraDeleteIcon](#cameradeleteicon) | `icons={{CameraDeleteIcon: MdDelete}}` | `FC<{ className?: string }>` | `TbTrash` |
| [CameraRotateIcon](#camerarotateicon) | `icons={{CameraRotateIcon: FaCameraRotate}}` | `FC<{ className?: string }>` | `TbCameraRotate` |
| [ContainerAddMoreIcon](#containeraddmoreicon) | `icons={{ContainerAddMoreIcon: IoAdd}}` | `FC<{ className?: string }>` | `TbPlus` |
| [FileDeleteIcon](#filedeleteicon) | `icons={{FileDeleteIcon: TiDelete}}` | `FC<{ className?: string }>` | `TbTrash` |
| [LoaderIcon](#loadericon) | `icons={{LoaderIcon: TbLoader2}}` | `FC<{ className?: string }>` | `TbLoader` |
## `CameraCaptureIcon`
Custom icon for the camera capture button.
**Example override:**
```tsx
import { FaCamera } from 'react-icons/fa'
;
```
## `CameraDeleteIcon`
Icon for removing captured camera images.
## `CameraRotateIcon`
Button to switch between front/back camera.
## `ContainerAddMoreIcon`
Icon shown in the "Add More Files" button. Appears when multiple uploads are allowed.
## `FileDeleteIcon`
Icon for removing selected files from the list.
## `LoaderIcon`
Animated icon shown during file processing.
**Customization example:**
```tsx
import { ImSpinner8 } from 'react-icons/im'
;
```
All icons inherit the component's dark mode styling automatically through the `className` prop when provided.
---
# Image Editor
https://useupup.com/docs/api-reference/upupuploader/image-editor/
The image editor is **on by default** in React and Preact. Pass
`imageEditor={false}` to opt out, or an options object to configure it.
The image editor ships in `@upupjs/react` and `@upupjs/preact` only (Preact
loads the real React editor as a lazy island). The Vue, Svelte, Angular, and
Vanilla packages intentionally stub it — a deliberate ruling, not a gap.
```tsx
```
Edited files flow back through core validation before upload.
## Options
```ts
type ImageEditorOptions = {
enabled?: boolean
display?: 'inline' | 'modal'
autoOpen?: 'never' | 'single' | 'always'
output?: {
mimeType?: string
quality?: number
fileName?: (original: File) => string
}
tabs?: (
'Adjust' | 'Annotate' | 'Filters' | 'Finetune' | 'Resize' | 'Watermark'
)[]
tools?: (
| 'Crop'
| 'Rotate'
| 'Flip'
| 'Brightness'
| 'Contrast'
| 'HSV'
| 'Blur'
| 'Text'
| 'Line'
| 'Rect'
| 'Ellipse'
| 'Polygon'
| 'Pen'
| 'Arrow'
| 'Image'
)[]
onOpen?: (file: UploadFile) => void
onCancel?: (file: UploadFile) => void
onSave?: (editedFile: UploadFile, originalFile: UploadFile) => void
}
```
- **`display`** (default `'inline'`) — `'inline'` swaps the editor into the
uploader panel; `'modal'` opens it in a portal over the page.
- **`autoOpen`** (default `'never'`) — `'single'` opens the editor
automatically when exactly one image is selected; `'always'` opens it for
every added image; `'never'` waits for the user to click the edit
affordance.
- **`output`** — controls the saved file: target `mimeType`, encode
`quality`, and a `fileName` callback deriving the edited file's name from
the original.
- **`tabs`** / **`tools`** — restrict which editor tabs and annotation tools
appear; omit either to get the full set.
- **`onOpen` / `onCancel` / `onSave`** — lifecycle callbacks; the same
moments are also emitted as `image-editor-open`, `image-editor-cancel`, and
`image-editor-save` [events](/docs/api-reference/events/).
---
# Optional Props
https://useupup.com/docs/api-reference/upupuploader/optional-props/
Every prop on this page is optional. The three upload-target props (`uploadEndpoint`, `serverUrl`, `resumable.endpoint`) and `provider` are covered in [Required Props](/docs/api-reference/upupuploader/required-props/); the 34 `on*` callbacks are covered in [Events & Callbacks](/docs/api-reference/events/).
Props are identical across React, Vue, Svelte, Angular, Vanilla, Preact, and Next — only `icons` and `style` differ in type per framework. Examples below use React syntax.
## Index
- **Behavior** — [`mode`](#mode), [`autoUpload`](#autoupload), [`quietCompletion`](#quietcompletion), [`enablePaste`](#enablepaste), [`disableDragDrop`](#disabledragdrop), [`folderUpload`](#folderupload), [`onBeforeFileAdded`](#onbeforefileadded)
- **Files & validation** — [`maxFiles`](#maxfiles), [`allowedFileTypes`](#allowedfiletypes), [`maxFileSize`](#maxfilesize), [`minFileSize`](#minfilesize), [`maxTotalFileSize`](#maxtotalfilesize), [`contentDeduplication`](#contentdeduplication)
- **Processing pipeline** — [`imageCompression`](#imagecompression), [`heicConversion`](#heicconversion), [`stripExifData`](#stripexifdata), [`thumbnailGenerator`](#thumbnailgenerator), [`checksumVerification`](#checksumverification), [`webWorker`](#webworker), [`imageEditor`](#imageeditor), [`processingEndpoint`](#processingendpoint), [`processingTimeout`](#processingtimeout)
- **Upload & reliability** — [`maxRetries`](#maxretries), [`maxConcurrentUploads`](#maxconcurrentuploads), [`resumable`](#resumable), [`crashRecovery`](#crashrecovery), [`networkAware`](#networkaware), [`metadata`](#metadata), [`cors`](#cors)
- **Sources & drives** — [`sources`](#sources), [`cloudDrives`](#clouddrives)
- **Appearance & UI** — [`theme`](#theme), [`animations`](#animations), [`mini`](#mini), [`className`](#classname), [`style`](#style), [`icons`](#icons), [`allowPreview`](#allowpreview), [`showBranding`](#showbranding), [`isProcessing`](#isprocessing)
- **Localization** — [`i18n`](#i18n)
- **Headless-only (not component props)** — [`plugins`](#plugins), [`pipeline`](#pipeline), [`workerTimeoutMs`](#workertimeoutms), [`isSuccessfulCall`](#issuccessfulcall)
## Behavior
### `mode`
`mode?: 'client' | 'server'` — default `'client'`, or `'server'` when `serverUrl` is set and `uploadEndpoint` is not.
In client mode the browser talks to storage directly and your server only signs URLs. In server mode the browser talks only to `serverUrl`, which proxies drive APIs and storage writes. Pick server mode when you cannot expose OAuth client secrets to the browser, or when uploads must pass through your own compliance or scanning layer. See [Client Mode vs Server Mode](/docs/guides/modes/).
```tsx
```
### `autoUpload`
`autoUpload?: boolean` — default `false`.
Starts the upload immediately when files are selected, so the user never presses an upload button. Combine with [`quietCompletion`](#quietcompletion) when the uploader is embedded in a form your app controls.
```tsx
```
### `quietCompletion`
`quietCompletion?: boolean` — default `false`.
When `true`, a successful run shows only a brief checkmark over the panel — no Done button, no summary, no follow-up calls to action. Use it when your app takes over after upload via the completion callbacks or events. The default `false` keeps the normal Done/continue-after-upload flow.
```tsx
```
### `enablePaste`
`enablePaste?: boolean` — default `false`.
Accepts clipboard paste (Ctrl+V / Cmd+V) as a file source. Pasted files run through the same validation, `onBeforeFileAdded` filter, and pipeline as picked files.
```tsx
```
### `disableDragDrop`
`disableDragDrop?: boolean` — default `false`.
Turns off drag-and-drop while keeping the browse/click path fully functional. Useful when the uploader sits inside a surface that owns its own drag behavior (a kanban card, a rich-text editor).
### `folderUpload`
`folderUpload?: { allowDrop?: boolean; showSelectFolderButton?: boolean }` — both default `false`.
`allowDrop` controls directory traversal when a user drops a folder onto the uploader. `showSelectFolderButton` controls whether the My Device source shows an explicit Select folder action.
```tsx
```
### `onBeforeFileAdded`
`onBeforeFileAdded?: (file: File) => boolean | File | undefined | Promise`
An async filter called once per file before it enters the file list. Return `false` to reject the file, a `File` to substitute a different one (renamed, re-wrapped, pre-scrubbed), or `true`/`undefined` to accept it unchanged. It runs before size and type validation, so it is the right place for rules the built-in restrictions cannot express.
```tsx
{
if (file.name.startsWith('~$')) return false
return new File([file], file.name.toLowerCase(), { type: file.type })
}}
/>
```
This is the one file-gating callback documented here because it changes what gets added rather than reporting what happened. Every other `on*` prop lives in [Events & Callbacks](/docs/api-reference/events/).
## Files & validation
### `maxFiles`
`maxFiles?: number` — default `10`.
The maximum number of files that can be in the list at once. Values below `1` are clamped to `1`, and [`mini`](#mini) forces the limit to `1` regardless of what you pass. When the resolved limit is `1` the underlying file input drops its `multiple` attribute. Attempts to exceed the limit surface through `onRestrictionFailed` with reason `LIMIT_EXCEEDED`.
```tsx
```
### `allowedFileTypes`
`allowedFileTypes?: string | string[]` — default `'*'`.
MIME patterns, file extensions, or preset names. Arrays are joined with commas; each comma-separated token is resolved against the preset table, and anything that is not a preset name passes through verbatim, so presets and raw patterns can be mixed freely.
```tsx
```
Preset names available from `@upupjs/core`'s `ACCEPT_PRESETS`: `images`, `video`, `audio`, `documents`, `spreadsheets`, `presentations`, `archives`, `code`, `fonts`, `3d`, `design`, `ebooks`, `photography`, `animation`, `ar`, `vector`, `cad`, `gis`, `data`, `markup`, `subtitles`, `email`, `calendar`, `contacts`, `disk`, `ml`, `database`, `certificates`, `firmware`, `executable`, `config`, `financial`, `scientific`, `medical`.
A rejected file fires `onFileTypeMismatch` and `onRestrictionFailed` with reason `TYPE_MISMATCH`.
### `maxFileSize`
`maxFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB' }` — default `{ size: 1, unit: 'GB' }`.
Per-file ceiling. Oversized files are rejected before upload and reported through `onRestrictionFailed` with reason `FILE_TOO_LARGE`.
```tsx
```
### `minFileSize`
`minFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | ... }` — no default (no floor).
Per-file floor, using the same `{ size, unit }` shape. Rejections report reason `FILE_TOO_SMALL`. Useful for catching truncated or zero-byte files before they reach storage.
### `maxTotalFileSize`
`maxTotalFileSize?: { size: number; unit: 'B' | 'KB' | 'MB' | 'GB' | ... }` — no default.
A ceiling on the combined size of everything currently in the list, checked as files are added. This is the right knob for per-submission quotas; `maxFileSize` alone cannot stop fifty acceptable files from adding up.
```tsx
```
### `contentDeduplication`
`contentDeduplication?: boolean` — default `false`.
Hashes each incoming file's bytes (SHA-256 via WebCrypto, with a non-cryptographic fallback where `crypto.subtle` is unavailable) and drops any file whose content already exists in the list. Because it is content-based, it catches the same file added twice under different names — which name-based checks miss.
## Processing pipeline
Pipeline steps run in a fixed order before upload: HEIC conversion, EXIF stripping, compression, thumbnail generation, then hashing. Each step's module is loaded lazily, so a step you do not enable costs nothing in your bundle.
### `imageCompression`
`imageCompression?: boolean` — default `false`.
Re-encodes images before upload. Defaults are quality `0.82` and a longest-edge cap of `1920` pixels. If a target byte size is configured, the step retries in `0.12` quality decrements down to a floor of `0.35` until the output fits. When no size target and no dimension cap are set and the re-encode came out larger than the original, the original file is uploaded untouched.
```tsx
```
The prop is typed as a boolean. To tune `quality`, `maxWidthOrHeight`, or `maxSizeMB`, pass the object form to the engine directly through the headless API — see [Headless Usage](/docs/guides/headless/).
```ts
const core = new UpupCore({
imageCompression: { quality: 0.7, maxWidthOrHeight: 2560, maxSizeMB: 2 },
})
```
### `heicConversion`
`heicConversion?: boolean` — default `false`.
Converts HEIC/HEIF images (the default iPhone camera format) to JPEG so they render in every browser. The decoder (`libheif-js`) is an optional dependency loaded on demand, so enabling this adds nothing to the mandatory bundle. Decode failures surface as a `pipeline-error` diagnostic rather than failing the upload.
### `stripExifData`
`stripExifData?: boolean` — default `false`.
Removes EXIF metadata — including GPS coordinates, device model, and capture timestamps — from images before they leave the browser. Enable it for anything user-generated and publicly served.
### `thumbnailGenerator`
`thumbnailGenerator?: boolean` — default `false`.
Generates a thumbnail for each image and attaches it to the file's `metadata.thumbnailUrl` (plus a `thumbnail.file` blob). Useful when your backend wants a preview it did not have to render itself.
```tsx
```
Like `imageCompression`, the prop is a boolean; the engine option accepts `{ width, height, quality }` for tuning.
### `checksumVerification`
`checksumVerification?: boolean` — default `false`.
Computes a SHA-256 hash of each file and carries it through with the upload so your server can verify the bytes it received match the bytes the browser sent. Worth the cost on large or irreplaceable files; skip it for avatars.
### `webWorker`
`webWorker?: boolean` — unset or `true` means auto, `false` forces the main thread.
Controls whether the pipeline (hash, HEIC, EXIF, thumbnail, compress) runs off the main thread. Auto mode uses a worker where the runtime supports it and falls back to the main thread transparently otherwise, so the UI stays responsive while a 40 MB photo re-encodes. Set `false` only when a host environment forbids workers.
```tsx
```
### `imageEditor`
`imageEditor?: boolean | ImageEditorOptions` — enabled by default; `display` defaults to `'inline'` and `autoOpen` to `'never'`.
Opens a crop/rotate/annotate editor on selected images. Omitting the prop leaves the editor **on** — the heavy editor bundle still loads lazily, only when a user actually opens it. Passing `false` (or `null`) disables it. The object form accepts `enabled`, `display` (`'inline' | 'modal'`), `autoOpen` (`'never' | 'single' | 'always'`), `output`, `tabs`, `tools`, and the `onOpen`/`onCancel`/`onSave` hooks.
```tsx
```
The editor ships in React and Preact only; Vue, Svelte, Angular, and Vanilla accept the prop but stub the UI. See [Image Editor](/docs/api-reference/upupuploader/image-editor/).
### `processingEndpoint`
`processingEndpoint?: string` — no default.
After each file finishes uploading, the uploader opens a Server-Sent Events connection to this URL with the storage key appended as `?key=...`. Use it to wait for server-side work — virus scanning, transcoding, thumbnail rendering — before treating the file as done. The completion event arrives through the `onFileProcessed` callback, documented in [Events & Callbacks](/docs/api-reference/events/).
```tsx
```
### `processingTimeout`
`processingTimeout?: number` — default `60000` (60 seconds).
Maximum milliseconds to wait for the `processingEndpoint` SSE event before closing the connection. Raise it for slow pipelines like video transcoding; the file itself is already uploaded either way.
## Upload & reliability
### `maxRetries`
`maxRetries?: number` — default `3`.
Counts **retries, not attempts**: a file is tried once and then retried up to `maxRetries` times, so the default of `3` means four attempts in total before the file is marked failed. A file rejected by [`isSuccessfulCall`](#issuccessfulcall) is not retried, since a business-logic rejection is unlikely to resolve itself.
```tsx
```
See [Error Handling](/docs/error-handling/) for what reaches your code after retries are exhausted.
### `maxConcurrentUploads`
`maxConcurrentUploads?: number` — default `3`.
How many files upload in parallel. Raising it helps on fast connections with many small files; lowering it to `1` is the safest choice when your presign route is rate-limited or your storage layer meters concurrent writes.
### `resumable`
`resumable?: ResumableUploadOptions` — no default (single-shot uploads).
Multipart and tus are both explicit protocols; there is no automatic selection.
```tsx
```
```tsx
```
The multipart form takes `thresholdBytes`, `chunkSizeBytes`, `persist`, `retryDelays`, `partTimeoutMs`, `maxConcurrentParts`, and `autoResume`. The tus form requires `endpoint` and takes `chunkSizeBytes`, `retryDelays`, `storeFingerprintForResuming`, `removeFingerprintOnSuccess`, `headers`, `metadata`, and `parallelUploads`; `tus-js-client` is an optional dependency loaded on demand. Full protocol details, including every multipart field, are in [Resumable Uploads](/docs/resumable-uploads/).
### `crashRecovery`
`crashRecovery?: boolean` — default `false`.
Persists upload state to IndexedDB so an interrupted session can resume after a page refresh or browser crash. State is saved on every state change while files are present and cleared once the run succeeds. Persistence is best-effort by design: a failed write warns in development and never breaks an upload. A normal unmount leaves the stored state intact, so the session stays recoverable.
```tsx
```
Pair it with `resumable` — recovering the file list is only useful if the transfer itself can pick up where it stopped.
The prop is a boolean. Supplying your own persistence layer instead of IndexedDB is a headless-only option: the engine's `crashRecovery` also accepts an object carrying a custom storage implementation, reachable through `new UpupCore(...)` or `useUpupUpload` — see [Headless Usage](/docs/guides/headless/).
### `networkAware`
`networkAware?: boolean` — default `true`.
Reacts to browser connectivity: going offline mid-upload pauses the run — with multipart `persist` on, that keeps the server-side session alive instead of burning whole-run retries against a dead network — and coming back online resumes it. The resume fires **only** when the pause was offline-initiated; a pause the user chose is never overruled by a connectivity change. A no-op outside a browser.
It is on by default, so the prop is only worth passing to opt out:
```tsx
```
`false` restores the plain fail-and-retry behavior, where a dropped connection burns retries and eventually fails the run.
### `metadata`
`metadata?: Record` — no default.
An arbitrary object sent with presign and multipart-init requests, so your server can attach ownership, tenancy, or routing information to the object it signs. It is the supported way to pass application context through the upload without inventing a side channel.
```tsx
```
### `cors`
`cors?: { dangerouslyAutoConfigure?: boolean; allowedOrigins: string[]; allowedMethods?: string[]; allowedHeaders?: string[]; maxAgeSeconds?: number }`
`allowedOrigins` is required when the object is present. `dangerouslyAutoConfigure` is named for what it does: it can mutate your bucket's CORS policy, so it must be enabled explicitly and scoped to origins you control. Treat it as a local-development convenience and configure CORS on the bucket for production — see [Credentials And CORS](/docs/credentials-configuration/).
```tsx
```
## Sources & drives
### `sources`
`sources?: UploadSource[]` — default `['local', 'url', 'camera', 'microphone', 'screen']`.
Controls which source panels are available and the order they appear in. Cloud drives are **not** in the default set — listing one here also requires configuring it in [`cloudDrives`](#clouddrives) (client mode) or on your `@upupjs/server` handler (server mode). `UploadSource` is the string form of the `FileSource` enum — import `FileSource` from `@upupjs/core` if you prefer named constants over string literals. Canonical IDs are:
```ts
type UploadSource =
| 'local'
| 'url'
| 'camera'
| 'microphone'
| 'screen'
| 'googleDrive'
| 'oneDrive'
| 'dropbox'
| 'box'
```
```tsx
```
### `cloudDrives`
`cloudDrives?: { googleDrive?: …; oneDrive?: …; dropbox?: …; box?: … }` — no default.
Browser-safe cloud provider configuration for client mode. Google Drive takes `clientId`, `apiKey`, and `appId`; OneDrive, Dropbox, and Box each take `clientId` plus an optional `redirectUri`.
```tsx
```
Only publishable values belong here. Use `serverUrl` for OAuth client secrets, token storage, server-side transfers, and compliance workflows — see [Server Mode — Setup](/docs/guides/server-mode-setup/).
## Appearance & UI
### `theme`
`theme?: { mode?: 'light' | 'dark' | 'system'; tokens?: …; slots?: … }`
`theme.mode` supports `light`, `dark`, and `system` (which follows `prefers-color-scheme` and updates live). `theme.tokens` overrides color, radius, shadow, and spacing values; `theme.slots` supplies per-component class overrides, keyed by component and then by the slot within it.
```tsx
```
Full token and slot tables are in [Theming](/docs/guides/theming/).
### `animations`
`animations?: boolean` — default `true`.
Decorative motion (entrance, hover, sheen, and success effects) is on by default. Set `animations={false}` to disable it. `prefers-reduced-motion` is honored automatically regardless of this prop, and essential motion — progress width, focus rings, the spinner — always runs.
```tsx
```
### `mini`
`mini?: boolean` — default `false`.
Renders a compact square uploader (roughly 280 px) instead of the standard fixed-height panel. Mini mode forces a single-file limit: [`maxFiles`](#maxfiles) resolves to `1` and the file input drops `multiple`.
### `className`
`className?: string` — no default.
Additional CSS class applied to the uploader's root container, alongside the required `upup-scope` class. Use it for layout and positioning; use [`theme`](#theme) slots for anything inside the panel.
### `style`
`style?: React.CSSProperties` (React/Preact/Next) or `Record` (Vue, Svelte, Angular, Vanilla) — no default.
Inline styles on the root container. This is one of the two genuinely framework-specific props; the type differs but the DOM result does not.
### `icons`
`icons?: UploaderIcons` — no default.
Replaces individual glyphs with your own components. React accepts `ContainerAddMoreIcon`, `FileDeleteIcon`, `CameraDeleteIcon`, `CameraCaptureIcon`, `CameraRotateIcon`, and `LoaderIcon`, each a component taking `className`. The component type is framework-specific — see [Icon Prop](/docs/api-reference/upupuploader/icon-prop/).
```tsx
```
### `allowPreview`
`allowPreview?: boolean` — default `true`.
Shows an expanded preview for files that can be rendered in the browser (images, video, audio). Set it to `false` for a filename-and-size list — a reasonable choice when users upload sensitive documents on shared screens.
### `showBranding`
`showBranding?: boolean` — default `true`.
Shows or hides the upup branding footer inside the panel.
### `isProcessing`
`isProcessing?: boolean` — default `false`.
A host-controlled busy flag. When `true`, the uploader shows a spinner in the panel corner and stops accepting new files: drag-over, drop, and paste handlers all short-circuit. Drive it from your own async work — form submission, server validation — when the uploader should look busy for a reason the uploader itself does not know about.
## Localization
### `i18n`
`i18n?: { bundle?: LocaleBundle; locale?: LocaleBundle | string; fallbackLocale?: LocaleBundle | string; overrides?: PartialMessages }`
Locale bundles are exported from `@upupjs/core/i18n`. Passing a bundle (via `bundle` or `locale`) enables ICU pluralization, namespaced key overrides, and the correct `lang`/`dir` on the root element; `bundle` takes precedence over `locale`. A bare BCP-47 string sets `lang`/`dir` only. `fallbackLocale` fills gaps when the active bundle is missing a key, and `overrides` merges per-key replacements on top of everything else.
```tsx
import { frFR } from '@upupjs/core/i18n'
;
```
The full key list and RTL notes are in [Localization](/docs/localization/).
## Headless-only options (UpupCore / useUpupUpload)
These four options are accepted by `UpupCore` but are **not** props on `` — the component's props-to-engine bridge does not forward them, so passing them to the component has no effect. Reach them by constructing the engine yourself with `new UpupCore(...)` or `useUpupUpload` — see [Headless Usage](/docs/guides/headless/).
### `plugins`
`plugins?: UpupPlugin[]` — no default.
Plugins registered at construction. A plugin is `{ name; init?(emitter) }`; `init` receives the core event bus, not the core itself. The built-in cloud-drive plugins are registered for you when you use the component.
### `pipeline`
`pipeline?: PipelineStep[]` — no default.
An explicit pipeline that replaces the automatic one. When you pass it, the boolean flags (`imageCompression`, `heicConversion`, and friends) no longer assemble the step list — you own the order and the contents.
```ts
const core = new UpupCore({
uploadEndpoint: '/api/upload-token',
pipeline: [myWatermarkStep(), compressStep({ quality: 0.7 })],
})
```
### `workerTimeoutMs`
`workerTimeoutMs?: number` — default `30000`.
Per-task timeout for web-worker pipeline work. On timeout the task falls back to main-thread processing; it never fails the file. Raise it if you routinely process very large images on low-end devices.
### `isSuccessfulCall`
`isSuccessfulCall?: (response: { status: number; headers: Record; body: unknown }) => boolean | Promise`
A custom success predicate for the storage response. Use it when a `2xx` is not sufficient proof — for example a gateway that returns `200` with an error body. A file rejected by this predicate is marked failed and is **not** retried, on the assumption a business-logic rejection will not resolve on a second attempt.
## See also
- [Required Props](/docs/api-reference/upupuploader/required-props/) — `uploadEndpoint`, `serverUrl`, `resumable.endpoint`, and `provider`.
- [Events & Callbacks](/docs/api-reference/events/) — all 34 `on*` props.
- [Ref API](/docs/api-reference/upupuploader/ref-api/) — imperative control from the host.
- [Headless Usage](/docs/guides/headless/) — the engine behind the component.
---
# Ref API
https://useupup.com/docs/api-reference/upupuploader/ref-api/
`UpupUploader` exposes the same behavior surface as `useUpupUpload`.
```tsx
const ref = useRef(null)
```
```ts
type UploaderRef = {
useUpload(): {
error?: string
files: UploadFile[]
loading: boolean
progress: number
upload(): Promise
resetState(): void
uploadFiles(
files: File[] | UploadFile[],
): Promise
setFiles(newFiles: File[]): void
replaceFiles(files: File[] | UploadFile[]): void
}
}
```
`UploaderRef` is exported from `@upupjs/react`; `UploadFile` is exported from
`@upupjs/core/contracts`.
---
# Required Props
https://useupup.com/docs/api-reference/upupuploader/required-props/
`UpupUploader` can run as a local file collector with no upload target. In that case users select files and you read the selected `File` objects from callbacks or hooks.
For actual uploads, configure exactly one target:
| Prop | Type | Use |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
| `uploadEndpoint` | `string` | Client-hosted presign route. The browser requests an upload URL, then uploads bytes directly to storage. |
| `serverUrl` | `string` | Server-hosted upup route from `@upupjs/server`. Your server owns provider calls, storage credentials, and transfer policy. |
| `resumable.endpoint` | `string` | External tus service endpoint when `resumable={{ protocol: 'tus', endpoint }}` is used. |
`provider` is required only when the selected target or server handler needs to know which storage backend to use.
## Client upload target
```tsx
import { UpupUploader } from '@upupjs/react'
import '@upupjs/react/styles'
export default function Uploader() {
return
}
```
The presign request body includes file details plus optional `metadata`:
```ts
type PresignRequest = {
name: string
type: string
size: number
metadata?: Record
}
```
The response must include the upload URL and may include signed upload headers:
```ts
type PresignedUrlResponse = {
key: string
uploadUrl: string
uploadHeaders?: Record
publicUrl?: string
downloadUrl?: string
expiresIn: number
}
```
## Server upload target
```tsx
```
`serverUrl` should point to a handler created by `@upupjs/server`.
---
# Code Examples
https://useupup.com/docs/code-examples/
## Local selection
```tsx
console.log(files)}
/>
```
## Client upload
```tsx
```
Your token endpoint returns upup's presign contract (`key`, `uploadUrl`,
`uploadHeaders`, `expiresIn`). Sign the URL however your storage prefers —
here, AWS S3 via the AWS SDK:
```ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3 = new S3Client({ region: process.env.S3_REGION! })
export async function POST(req: Request) {
const body = await req.json()
const key = `uploads/${crypto.randomUUID()}/${body.name}`
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
ContentType: body.type,
}),
{ expiresIn: 3600 },
)
return Response.json({
key,
uploadUrl,
uploadHeaders: { 'Content-Type': body.type },
expiresIn: 3600,
})
}
```
## Server upload
```tsx
```
## Multipart
```tsx
```
## tus
```tsx
```
---
# upup vs FilePond
https://useupup.com/docs/comparisons/upup-vs-filepond/
FilePond (by PQINA) is a polished, highly accessible JavaScript uploader known for
its silky image-optimization UX and a broad catalog of plugins, with first-party
adapters for React, Vue, Angular, Svelte, and jQuery. It renders its own UI
component and focuses on uploading local files and URLs to your own server
endpoint. It does not include cloud-drive browsing, camera, or screen capture out
of the box, and its full-featured image editing is a separate product (Pintura).
upup is a headless core plus native UI that adds cloud drives, camera and screen
capture, and an S3-compatible server with an HMAC-signed trust model.
## At a glance
| Feature | FilePond | upup |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| Native first-party UI | Vanilla JS core + adapters: React, Vue, Angular, Svelte, jQuery | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | No — renders its own UI component | Yes (`@upupjs/core`) |
| License / pricing | MIT, free & open source (the Pintura image editor is a separate commercial product) | MIT, free & open source |
| Self-host incl. S3-compatible | Yes — uploads to your own server endpoint (process / revert / restore), with chunk uploads; no built-in S3 signing | Yes — `@upupjs/server` presigns and proxies to any S3-compatible storage |
| Cloud-drive sources | No (local files, directories, blobs, local/remote URLs, Data URIs, paste) | Google Drive, OneDrive, Dropbox, Box |
| Camera / screen capture | No | Yes (both) |
| Image editor | Via plugins (crop / resize / transform); a full editor is Pintura (separate/commercial) | Yes — React/Preact only |
| Resumable uploads | Chunked uploads (server-driven) | Yes — optional (tus or S3 multipart) |
| i18n | Configurable label strings (no locale bundles) | Yes (ICU locale bundles) |
## Choose FilePond if
- You want best-in-class image preview and optimization UX, smooth animations, and
strong accessibility for a local-file upload flow.
- You need a jQuery adapter, or you're happy uploading to your own server endpoint.
- You want a mature, plugin-rich, single-purpose uploader and will add any cloud
sources yourself.
## Choose upup if
- You need cloud-drive sources (Google Drive, OneDrive, Dropbox, Box), camera, or
screen capture built in.
- You want a headless core you can drive from your own UI, plus native UI across
six frameworks with a shared DOM contract.
- You want an included server that presigns and proxies to any S3-compatible
storage behind an HMAC-signed trust model.
---
# upup vs react-dropzone
https://useupup.com/docs/comparisons/upup-vs-react-dropzone/
react-dropzone is a small, focused React hook (`useDropzone`) — with a matching
wrapper component — for building an HTML5-compliant drag-and-drop file-selection
zone. It is a **primitive, not a full uploader**: it hands you the selected or
dropped `File` objects and leaves the upload, UI, progress, previews, cloud
sources, and everything else to you. That's exactly what you want when you only
need drag-and-drop in React and will build the rest yourself. upup is a full
uploader — a headless core, native UI for six frameworks, and an optional server —
so the two really solve different problems. This page is here because teams often
start with the primitive and later need the whole flow.
## At a glance
| Feature | react-dropzone | upup |
| ----------------------- | --------------------------------------------------------- | ------------------------------------------------------------------ |
| Scope | Drag-and-drop file-selection primitive (hook + component) | Full uploader: selection, UI, upload pipeline, and server |
| Native first-party UI | No — you render your own; React only | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | Yes (a hook) — but file selection only, no uploading | Yes (`@upupjs/core`) — a full upload engine |
| License / pricing | MIT, free & open source | MIT, free & open source |
| Uploading / self-host | You implement uploads yourself, to any backend | `@upupjs/server` presigns and proxies to any S3-compatible storage |
| Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box |
| Camera / screen capture | No | Yes (both) |
| Image editor | No | Yes — React/Preact only |
| Resumable uploads | No | Yes — optional (tus or S3 multipart) |
| i18n | No | Yes (ICU locale bundles) |
## Choose react-dropzone if
- You only need a drag-and-drop zone in React and will build the upload, UI, and
progress yourself.
- You want the smallest, most unopinionated primitive, with no upload logic baked
in.
- You already have an upload backend and UI and just need clean file selection.
## Choose upup if
- You want the drag-and-drop **and** the progress bar, previews, cloud drives,
camera, resumable uploads, and a server — without assembling them yourself.
- You need more than React: Vue, Svelte, Angular, Vanilla JS, and Preact, all
sharing one DOM contract.
- You'd rather configure a complete uploader than build one from a primitive.
---
# upup vs UploadThing
https://useupup.com/docs/comparisons/upup-vs-uploadthing/
UploadThing (by Ping Labs) is a hosted file-upload **service** for full-stack
TypeScript apps: you install its open-source SDK, drop in its `UploadButton` /
`UploadDropzone` components, and files land in UploadThing's managed storage — no
bucket or infrastructure to run. It's a fast, type-safe way to ship uploads,
especially on Next.js. The core trade-off against upup is the model: UploadThing
is a paid managed service that stores your files on its own infrastructure and is
focused on the upload button/dropzone, while upup is an MIT, self-hosted library
that writes to **your** S3-compatible storage and adds cloud drives, camera and
screen capture, image editing, and native UI for six frameworks.
## At a glance
| Feature | UploadThing | upup |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| Model | Hosted SaaS (managed storage + CDN) | Self-hosted library (your storage) |
| Native first-party UI | React components (`UploadButton` / `UploadDropzone`) + adapters for Next.js and other full-stack frameworks (Solid, Svelte, Vue, Nuxt, Expo, and more) | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | React hooks (coupled to the service) | Yes (`@upupjs/core`, framework-agnostic) |
| License / pricing | Open-source SDK (MIT); the service is a paid SaaS with a free tier and usage-based plans | MIT, free & open source (you pay only for your own storage) |
| Storage / self-host | Managed — files are stored on UploadThing's infrastructure | Any S3-compatible storage you own (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) via `@upupjs/server` |
| Cloud-drive sources | No | Google Drive, OneDrive, Dropbox, Box |
| Camera / screen capture | No | Yes (both) |
| Image editor | No | Yes — React/Preact only |
| Resumable uploads | Not a primary feature | Yes — optional (tus or S3 multipart) |
| i18n | No | Yes (ICU locale bundles) |
## Choose UploadThing if
- You want zero storage infrastructure — a managed service that stores files and
serves them over a CDN for you.
- You're building on Next.js (or another supported full-stack framework) and want
the fastest, most type-safe path to a working upload button.
- You're comfortable with a hosted, paid service owning the storage layer.
## Choose upup if
- You want files kept in your own S3-compatible storage, avoiding a per-GB SaaS
bill and third-party custody of your data.
- You need cloud-drive sources, camera, screen capture, or image editing.
- You want native UI across six frameworks and a fully MIT, self-hosted stack —
including an HMAC-signed server trust model.
---
# upup vs Uppy
https://useupup.com/docs/comparisons/upup-vs-uppy/
Uppy (by Transloadit) is the most established open-source JavaScript uploader,
with a large, battle-tested plugin ecosystem and Companion, its mature server for
fetching files from remote sources. Both Uppy and upup are MIT-licensed and share
the same shape — a headless core plus framework UI, cloud-drive sources, camera,
image editing, and resumable uploads. The practical differences are in the UI
model and the server: upup ships a **native, DOM-identical UI implemented in each
framework's own idioms** across six frameworks (including Svelte 5, Angular
standalone, a framework-free build, and Preact), and its `@upupjs/server` includes
an HMAC-signed upload-token trust model out of the box. Uppy mounts one UI through
framework wrappers and has the deeper, more widely deployed plugin catalog.
## At a glance
| Feature | Uppy | upup |
| ----------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Native first-party UI | Vanilla JS, React, Vue, Svelte, Angular | React, Vue, Svelte, Angular, Vanilla JS, Preact |
| Headless core | Yes (`@uppy/core`; recent major versions add headless components and hooks) | Yes (`@upupjs/core`) |
| License / pricing | MIT, free & open source (Transloadit is an optional paid hosted service) | MIT, free & open source |
| Self-host incl. S3-compatible | Yes — your endpoint / tus server / S3 via the AWS S3 plugin; Companion (self-hostable) for remote sources | Yes — `@upupjs/server` presigns and proxies to any S3-compatible storage (AWS, MinIO, R2, Spaces, Wasabi, Backblaze) |
| Cloud-drive sources | Google Drive, Dropbox, OneDrive, Box, Google Photos, Unsplash, and more (via Companion) | Google Drive, OneDrive, Dropbox, Box |
| Camera / screen capture | Yes (Webcam, Screen Capture plugins) | Yes (both) |
| Image editor | Yes (Image Editor plugin) | Yes — React/Preact only |
| Resumable uploads | Yes (tus, S3 multipart) | Yes — optional (tus or S3 multipart) |
| i18n | Yes (locale packs) | Yes (ICU locale bundles) |
## Choose Uppy if
- You want the most mature, most widely deployed option, with the largest and
most battle-tested plugin ecosystem.
- You need Companion's server-side remote fetching for many providers (including
Google Photos and Unsplash) or value its long production track record.
- Your team has already standardized on Uppy's plugin model.
## Choose upup if
- You want native UI implemented in each framework's idioms — including Svelte 5,
Angular standalone, a framework-free build, and Preact — held DOM-identical by a
parity harness, rather than one UI mounted through wrappers.
- You want a server with an HMAC-signed upload-token trust model built in, so the
client can never assert the object key or S3 `uploadId` it writes to.
- You want cloud drives, camera, screen capture, image compression/HEIC, and
resumable uploads from one MIT package set with a single DOM/parity contract.
---
# Credentials and CORS
https://useupup.com/docs/credentials-configuration/
Client mode should only expose browser-safe values such as OAuth client IDs or
public Google Drive API keys. Storage credentials and OAuth client secrets
belong in server mode.
## CORS
For browser direct uploads, configure your bucket to allow the exact app
origins that will upload files.
```tsx
```
`dangerouslyAutoConfigure` can mutate storage CORS policy. Keep it for local
setup and controlled admin tooling, not broad production defaults.
## Server Credentials
Use `@upupjs/server` for storage access keys, OAuth client secrets, provider
token refresh, audit logging, and server-side transfers.
---
# Error Handling
https://useupup.com/docs/error-handling/
upup reports failures as a typed `UpupError` (or one of its subclasses). Every
`UpupError` carries:
- `message` — human-readable description
- `code` — a stable string from the `UpupErrorCode` enum
- `retryable` — whether the failure is transient and worth retrying
- `status` — the originating HTTP status, when the error came from a response
## Error types
Import the error classes and the code enum from `@upupjs/core`:
```typescript
import {
UpupError,
UpupErrorCode,
UpupAuthError,
UpupNetworkError,
UpupValidationError,
UpupQuotaError,
UpupStorageError,
UpupConfigError,
} from '@upupjs/core'
```
| Class | Raised when | Extra fields |
| --------------------- | ----------------------------------------------------------- | ------------------------------ |
| `UpupError` | Base class for every upup error | `code`, `retryable`, `status?` |
| `UpupValidationError` | A file fails a size / type / count check | `reason`, `file` |
| `UpupNetworkError` | A fetch / XHR fails (marked `retryable`) | `status?` |
| `UpupStorageError` | An S3 / storage operation fails | `provider`, `operation` |
| `UpupAuthError` | A cloud-drive OAuth / provider call fails | `provider` |
| `UpupQuotaError` | A configured quota is exceeded | `limit`, `used` |
| `UpupConfigError` | Configuration is missing or invalid (e.g. no upload target) | — |
The `code` values come from the `UpupErrorCode` enum. See [Error
Codes](/docs/api-reference/error-codes/) for the complete list with the trigger
behind each one, or [Error Monitoring](/docs/guides/error-monitoring/) for the
codes you'll tag most often.
## Inspecting an error
Upload failures surface through the core `upload-error` event with the full
`Error` object and the file that failed. Narrow with `instanceof`, or switch on
`code`:
```typescript
import { UpupError, UpupErrorCode, UpupValidationError } from '@upupjs/core'
// error is the Error object from an upload-error event (see Error Monitoring).
function describeUploadError(error: unknown): string {
if (error instanceof UpupValidationError) {
return `${error.file.name} rejected: ${error.reason}`
}
if (error instanceof UpupError) {
if (error.code === UpupErrorCode.PRESIGN_FAILED) {
return 'Your token endpoint returned an error.'
}
return error.retryable
? 'Transient failure — safe to retry.'
: 'Permanent failure — needs attention.'
}
return 'Unknown error.'
}
```
Both surfaces — the headless core's `upload-error` event (full `Error`) and the
React `onError` prop (message string) — plus wiring them into an error tracker
are covered in [Error Monitoring](/docs/guides/error-monitoring/).
## Retry behavior
Configure automatic retries with the [`maxRetries`](/docs/api-reference/upupuploader/optional-props/#maxretries) prop. Each file upload is retried up to that many times before it counts as a failure.
```tsx
```
Once a file has exhausted its attempts and the run is marked failed, a manual **"Retry"** button appears in the UI. It appears whether or not `maxRetries` is set — that prop controls how many automatic attempts happen before the failure, not whether the manual button is offered.
### Resumable upload recovery
When [resumable multipart uploads](/docs/resumable-uploads/) are enabled (`resumable={{ protocol: 'multipart' }}`), the failure UI changes and so does what recovery costs:
- The button shown **after a failure** is labelled **"Resume"** instead of "Retry".
- It invokes the same retry command as a non-resumable upload, but the file continues from its last completed part rather than restarting. The failed attempt left its server-side parts in place on purpose, and the retry re-attaches to them through `POST /multipart/resume`.
Note that this is not the **"Resume"** button shown while a run is _paused_ —
that one is a separate control on the pause/resume path. Only the failure-state
button is described here.
Part-level continuation survives a page refresh too, provided the file comes
back: pair `resumable` with `crashRecovery` and the restored file re-attaches
mid-transfer. It is on by default (`persist: true`) and degrades to a fresh
upload — never to a failure — whenever the session cannot be trusted. What
that costs your bucket, and the `AbortIncompleteMultipartUpload` lifecycle rule
it obliges you to configure, are covered in [Cross-reload
resume](/docs/resumable-uploads/#cross-reload-resume).
In client mode (`uploadEndpoint`), where multipart cannot run, tus remains the
resumable option: it delegates fingerprinting and offset recovery to
`tus-js-client`. See [Resumable Uploads](/docs/resumable-uploads/).
---
# Getting Started
https://useupup.com/docs/getting-started/
upup is a file uploader with a native UI for **React, Vue, Svelte, Angular,
Vanilla JS, and Preact**, built on a shared headless core (`@upupjs/core`), with
an optional server mode for signed uploads and cloud-drive sources (Google
Drive, OneDrive, Dropbox, Box). Every package renders the same UI.
Here is the uploader you are about to install — try it:
This guide uses React. To start from another framework, use its quickstart —
each mounts the same uploader with the same options:
- [React quickstart](/docs/quickstarts/react/)
- [Vue quickstart](/docs/quickstarts/vue/)
- [Svelte quickstart](/docs/quickstarts/svelte/)
- [Angular quickstart](/docs/quickstarts/angular/)
- [Vanilla JS quickstart](/docs/quickstarts/vanilla/)
- [Preact quickstart](/docs/quickstarts/preact/)
Install the React package and styles:
```bash
npm i @upupjs/react
```
```tsx
import { UpupUploader } from '@upupjs/react'
import '@upupjs/react/styles'
```
## Pick your framework
The same uploader mounts from every package with the same options. Pick your
framework — the choice is remembered as you move through the docs, and you can
deep-link a framework with `?fw=vue`.
## Local file collection
With no upload target, upup lets users select files and gives you `File`
objects through callbacks and hooks.
```tsx
{
console.log(files)
}}
/>
```
Calling `upload()` without a target returns a typed no-target error.
## What happens to your file
Whichever target you pick, every file runs through the same client-side pipeline
before it leaves the browser — validated, optionally compressed (HEIC images are
converted), then uploaded, with the heavy work offloaded to a web worker.
## Client uploads
Use `uploadEndpoint` when your app signs upload URLs and the browser uploads
bytes directly to storage.
```tsx
```
## Server uploads
Use `@upupjs/server` when provider OAuth, token storage, storage credentials,
or transfer policy should live server-side.
```bash
npm i @upupjs/react @upupjs/server
```
```tsx
```
```ts
import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server'
const handler = createUpupHandler({
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!,
},
// Required, server-only: a stable, high-entropy secret (min 16 chars),
// shared across every server instance. createUpupHandler throws without it.
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
tokenStore: new InMemoryTokenStore(),
getUserId: async () => 'user_123',
})
export const GET = handler
export const POST = handler
```
---
# Accessibility
https://useupup.com/docs/guides/accessibility/
upup's accessibility semantics are not an opt-in layer. Roles, labels, live
regions, focus handling, and the reduced-motion gate are part of the default
render — there is no `a11y` prop to switch on, and nothing here requires extra
markup from you.
Because every framework package renders the same DOM (React is the source of
truth and the cross-framework parity harness compares normalized DOM plus an
accessibility snapshot for React, Vue, Svelte, Angular, Vanilla, and Preact),
the behavior described on this page is identical in all six. A nightly CI job
(`pnpm run e2e:a11y`) runs axe-core against every framework and fails the build
on any new serious or critical violation.
## Keyboard interaction
Every actionable element in the uploader is a real `