Documentation menu

Migrating from v1 to v3

v1 shipped as a single React package, upup-react-file-uploader. v3 is a ground-up rewrite: a framework-agnostic headless core (@useupup/core) with a native UI for React, Vue, Svelte, Angular, Vanilla JS, and Preact, an optional server package (@useupup/server) for signed uploads and server-proxied cloud drives, and a Next.js package (@useupup/next). The React component keeps the same name — UpupUploader — and the same idea, but most props were renamed or restructured. This is a major upgrade, not a drop-in bump; budget time to sweep your props.

Rough time budget: 1–2 hours for a typical single-UpupUploader app, longer if you customized styling via classNames or built programmatic upload control on the ref API.

What changed at a glance

  • Package rename. upup-react-file-uploader@useupup/react. The /styles and /server subpaths move to @useupup/react/styles and the standalone @useupup/server package.
  • Six frameworks. The React UI is the canon; @useupup/vue, @useupup/svelte, @useupup/angular, @useupup/vanilla, and @useupup/preact render the same DOM.
  • Headless core. @useupup/core holds the engine, the error taxonomy, i18n bundles, and theme contracts. You can build a fully custom UI on it.
  • Two upload modes. v1's single tokenEndpoint becomes either uploadEndpoint (client mode — your route signs URLs, the browser uploads directly) or mode="server" + serverUrl (server mode — the browser talks only to @useupup/server, which holds credentials and proxies drives).
  • Prop renames. limitmaxFiles, acceptallowedFileTypes, darktheme.mode, classNamestheme.slots, uploadAdapterssources, driveConfigscloudDrives, customPropsmetadata, enableAutoCorsConfigcors, localePack/translationsi18n. Full table below.
  • Error taxonomy. The UploadError / UploadErrorType pair becomes UpupError + the UpupErrorCode enum (with typed subclasses), exported from @useupup/core.

Install

sh
npm uninstall upup-react-file-uploader
npm i @useupup/react

Add @useupup/server only if you adopt server mode:

sh
npm i @useupup/server

If you catch upload errors by type (see Error handling), also add the core package so you can import the error classes directly:

sh
npm i @useupup/core

Then update your imports:

diff
- import { UpupUploader, UpupProvider } from 'upup-react-file-uploader'
- import 'upup-react-file-uploader/styles'
+ import { UpupUploader } from '@useupup/react'
+ import '@useupup/react/styles'

UpupProvider and UploadAdapter are gone as exports. provider is now a plain string ("aws"), and upload methods are configured with sources (string ids), not the UploadAdapter enum. If you need the storage-provider type, @useupup/react re-exports StorageProvider from @useupup/core.

Props: v1 → v3

Every v1 UpupUploader prop, and where it went in v3. Exact v1 names are on the left; exact v3 names on the right.

v1 propv3 propNotes
provider={UpupProvider.AWS} (required)provider="aws" (optional)Now a lowercase string, not the UpupProvider enum. Enum values are unchanged (aws, azure, backblaze, digitalocean) and v3 adds many more S3-compatible ids (r2, wasabi, minio, gcs, …). No longer required — omit it for local-only file selection.
tokenEndpoint="/api/upload-token" (required)uploadEndpoint="/api/upload-token" or mode="server" + serverUrl="/api/upup"Renamed and split into client mode vs server mode. See Storage & upload path.
accept="image/png"allowedFileTypes="image/png"Renamed. Accepts a string, a string[], or preset names (e.g. "images", "documents").
dark={true}theme={{ mode: 'dark' }}theme.mode accepts 'light', 'dark', or 'system'.
classNames={{ … }} (flat map)theme={{ slots: { … } }} (nested)The flat keys (adapterButton, progressBarInner, …) become nested slot paths — see the theming guide for the full slot structure.
limit={5}maxFiles={5}Renamed. Default changed from 1 to 10 — see Gotchas.
mini={true}mini={true}Unchanged.
maxFileSize={{ size: 20, unit: 'MB' }}maxFileSize={{ size: 20, unit: 'MB' }}Unchanged shape. v3 adds minFileSize and maxTotalFileSize (same object shape).
maxRetries={3}maxRetries={3}Unchanged.
resumable={{ mode: 'multipart' }}resumable={{ protocol: 'multipart' }}The key mode was renamed to protocol. v3 also supports { protocol: 'tus', endpoint }.
uploadAdapters={[UploadAdapter.INTERNAL, …]}sources={['local', …]}Enum array → string-id array. Mapping below.
driveConfigs={{ … }}cloudDrives={{ … }}Renamed, keys are camelCased. See Sources & cloud drives.
imageEditor={true}imageEditor={true}Unchanged (boolean | ImageEditorOptions). React/Preact only.
localePack={fr_FR}i18n={{ locale: frFR }}Locale bundles now live under i18n. v1 exported snake_case bundles; v3's are camelCase, imported from @useupup/core (enUS, frFR, arSA, deDE, esES, jaJP, koKR, zhCN, zhTW).
translations={{ browseFiles: '…' }}i18n={{ overrides: { … } }}Per-key overrides move under i18n.overrides, and are namespaced (e.g. { fileList: { uploadFiles: '…' } }) rather than flat.
customProps={{ … }}metadata={{ … }}Renamed. Still forwarded to your upload route.
enableAutoCorsConfig={true}cors={{ dangerouslyAutoConfigure: true, allowedOrigins: [...] }}Replaced by the cors object. Auto-configuration is now explicitly opt-in and named dangerouslyAutoConfigure.
shouldCompress={true}imageCompression={true}Renamed (boolean → boolean). Same semantics.
showSelectFolderButton={true}folderUpload={{ showSelectFolderButton: true }}Moved under the folderUpload object.
allowPreview={true}allowPreview={true}Unchanged.
isProcessing={busy}isProcessing={busy}Unchanged.
icons={{ … }}icons={{ … }}Unchanged (the per-framework component types differ).

v3 also adds many new props with no v1 equivalent — among them autoUpload, thumbnailGenerator, heicConversion, stripExifData, checksumVerification, contentDeduplication, crashRecovery, webWorker, maxConcurrentUploads, enablePaste, and processingEndpoint. See the React quickstart for the modern surface.

Sources & cloud drives

uploadAdapters (an UploadAdapter enum array) becomes sources (a string-id array). The order still controls tab order.

diff
- import { UpupUploader, UploadAdapter } from 'upup-react-file-uploader'
- <UpupUploader
-   uploadAdapters={[UploadAdapter.INTERNAL, UploadAdapter.GOOGLE_DRIVE]}
- />
+ import { UpupUploader } from '@useupup/react'
+ <UpupUploader sources={['local', 'googleDrive']} />

Adapter → source id mapping:

v1 UploadAdapterv3 sources id
INTERNAL'local'
GOOGLE_DRIVE'googleDrive'
ONE_DRIVE'oneDrive'
DROPBOX'dropbox'
LINK'url'
CAMERA'camera'
(new)'box', 'screen', 'microphone'

v3's default sources add camera, microphone, and screen recording

v1's default surface was effectively local files plus link imports. v3 defaults sources to ['local', 'url', 'camera', 'microphone', 'screen'], so an uploader migrated without a sources prop silently grows three capture panels. Opening one asks the user for camera or microphone permission, and what it produces is a recording: video/webm for screen, audio/webm for microphone, image/jpeg for a camera photo. The exact recording type comes from the browser's MediaRecorder and may carry a ;codecs=… suffix, so match it with a video/*-style wildcard rather than an exact string. If those types are not in your allowedFileTypes (or your server's allowedTypes), a user can record a clip and then watch it be rejected — a dead end with no way forward.

Pass sources explicitly to keep the v1 surface:

tsx
<UpupUploader sources={['local', 'url']} />

driveConfigs becomes cloudDrives, and the snake_case keys become camelCase:

diff
- driveConfigs={{
-   googleDrive: {
-     google_client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
-     google_api_key: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!,
-     google_app_id: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!,
-   },
-   oneDrive: {
-     onedrive_client_id: process.env.NEXT_PUBLIC_ONEDRIVE_CLIENT_ID!,
-   },
- }}
+ cloudDrives={{
+   googleDrive: {
+     clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
+     apiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!,
+     appId: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!,
+   },
+   oneDrive: {
+     clientId: process.env.NEXT_PUBLIC_ONEDRIVE_CLIENT_ID!,
+   },
+ }}

oneDrive, dropbox, and box each take { clientId, redirectUri? }; googleDrive takes { clientId, apiKey, appId }.

Theming & i18n

Two v1 props (dark, classNames) collapse into one theme object, and two i18n props (localePack, translations) collapse into one i18n object.

diff
  <UpupUploader
-   dark={isDark}
-   classNames={{ progressBarInner: 'my-fill', adapterButton: 'my-btn' }}
-   localePack={fr_FR}
-   translations={{ browseFiles: 'Send' }}
+   theme={{
+     mode: isDark ? 'dark' : 'light',
+     slots: {
+       progressBar: { fill: 'my-fill' },
+       sourceSelector: { sourceButton: 'my-btn' },
+     },
+   }}
+   i18n={{
+     locale: frFR,
+     overrides: { fileList: { uploadFiles: 'Send' } },
+   }}
  />

The flat classNames keys map one-to-one to nested slot paths; the theming guide documents the full slot structure. For app-wide theming you can also wrap your tree in UpupThemeProvider (exported from @useupup/react).

Events

Most handlers keep their names and shapes. Two are worth a closer look:

v1 eventv3 eventChange
onFileRemoveonFileRemovedRenamed to past tense — the old spelling is gone, not aliased.
onFilesUploadCompleteonFilesUploadCompleteName unchanged; item type changed. Its argument is a list of files in both versions; v1 typed it FileWithParams[] (and v1's own docs mislabeled the items as storage "keys"), v3 types it UploadFile[] — the same type swap every file handler gets.

Unchanged names: onFilesSelected, onFileClick, onFileTypeMismatch, onFileUploadComplete, onFileUploadStart, onFileUploadProgress, onFilesDragOver, onFilesDragLeave, onFilesDrop, onFilesUploadProgress, onIntegrationClick, onPrepareFiles, onDoneClicked, onWarn, and onError.

New in v3: onUploadStart, onUploadComplete, onStatusChange, onRestrictionFailed, onBeforeFileAdded, and onFileProcessed.

onFilesSelected, onFileClick, and the per-file complete/start handlers now receive v3 UploadFile objects (which carry id, key, status, and the underlying File) instead of v1's FileWithParams. Property access like file.name and file.type still works.

Ref API (programmatic control)

The ref pattern survives with a renamed type. UpupUploaderRef becomes UploaderRef, and its useUpload() method returns a superset of the v1 shape:

diff
- import { UpupUploader, UpupUploaderRef } from 'upup-react-file-uploader'
- const ref = useRef<UpupUploaderRef | null>(null)
+ import { UpupUploader, type UploaderRef } from '@useupup/react'
+ const ref = useRef<UploaderRef | null>(null)

  // ref.current.useUpload() still returns { files, loading, progress, upload, error }
  // v3 adds: resetState, uploadFiles, setFiles, replaceFiles

v3 also introduces a first-class headless hook, useUpupUpload, which is the recommended way to drive uploads from your own UI. It returns reactive files, status, progress, and error, plus addFiles, upload, pause, resume, cancel, retry, and an on(event, handler) subscription — no ref polling required.

tsx
import { useUpupUpload } from '@useupup/react'

const { files, status, progress, error, addFiles, upload } = useUpupUpload({
    provider: 'aws',
    uploadEndpoint: '/api/upload-token',
})

Error handling

v1 threw UploadError carrying an UploadErrorType enum. v3 replaces it with UpupError (base class) and typed subclasses, keyed by the UpupErrorCode enum. Both are exported from @useupup/core.

diff
- import { UploadError, UploadErrorType } from 'upup-react-file-uploader'
+ import { UpupError, UpupErrorCode } from '@useupup/core'

  try {
    await doUpload()
  } catch (e) {
-   if (e instanceof UploadError && e.type === UploadErrorType.EXPIRED_URL) {
+   if (e instanceof UpupError && e.code === UpupErrorCode.PRESIGN_FAILED) {
      // handle re-signing
    }
  }

Key differences:

  • The discriminator moved from error.type (an UploadErrorType value) to error.code (an UpupErrorCode value, a string). retryable and status are still present.
  • v3 ships typed subclasses you can narrow on: UpupAuthError, UpupNetworkError, UpupValidationError, UpupQuotaError, UpupStorageError, and UpupConfigError.
  • UpupErrorCode is a broader, more specific set than v1's eight types — e.g. FILE_TOO_LARGE, TYPE_MISMATCH, LIMIT_EXCEEDED, PRESIGN_FAILED, CORS_ERROR, AUTH_EXPIRED, AUTH_DENIED, AUTH_REQUIRED, NETWORK_ERROR, UPLOAD_FAILED, QUOTA_EXCEEDED, STORAGE_ERROR.

The simple onError={(message) => …} prop is unchanged (it still receives a string), and the reactive error from useUpupUpload() is now a typed UpupError rather than an opaque value. The ref's useUpload().error stays the plain message string it was in v1.

Storage & upload path

v1 had one path: a tokenEndpoint you implemented server-side with s3GeneratePresignedUrl (from upup-react-file-uploader/server). The browser received a presigned URL and uploaded bytes directly to storage. v3 keeps that model as client mode and adds a server-mode option that holds your credentials and proxies drive transfers.

Client mode (closest to v1)

Rename tokenEndpoint to uploadEndpoint. Your route still returns a presigned URL per file.

diff
- <UpupUploader provider={UpupProvider.AWS} tokenEndpoint="/api/upload-token" />
+ <UpupUploader provider="aws" uploadEndpoint="/api/upload-token" />

You can keep hand-rolling that route, or adopt @useupup/server's handler (below), which implements presign, multipart, and drive OAuth for you. The v1 server helpers (s3GeneratePresignedUrl, the s3*MultipartUpload family, azureGenerateSasUrl) are superseded by the handler's /presign and /multipart/* routes.

Server mode (new)

Point the uploader at a @useupup/server route and let it hold the credentials:

tsx
<UpupUploader provider="aws" mode="server" serverUrl="/api/upup" />
ts
import { createUpupHandler, InMemoryTokenStore } from '@useupup/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!,
    // OAuth client SECRETS live here, never in the browser:
    providers: {
        googleDrive: { clientId: '…', clientSecret: '…' },
    },
    tokenStore: new InMemoryTokenStore(),
    getUserId: async req => resolveUser(req),
})

export const GET = handler
export const POST = handler

Server mode is secure-by-default: /presign and /multipart/init return 403 AUTH_REQUIRED unless you configure auth, getUserId, or the explicit allowAnonymousUploads: true. Every upload is bound to an HMAC-signed token (key + uploadId + size), so a leaked presigned URL cannot be replayed for a different object or a larger body.

Security upgrade

  • v1's client-direct model: cloud-drive integration was client-side only — the Google Drive clientId/apiKey and OneDrive clientId were public identifiers shipped to the browser (the normal client-side OAuth model; those are not secrets).
  • What v1 couldn't do: keep the OAuth client secret and the drive access tokens off the client.
  • What v3 server mode adds: the client talks to your serverUrl for OAuth and drive access, and @useupup/server performs the OAuth exchange and stores tokens in your TokenStore. It also proxies cloud-drive transfers — the server fetches the drive file and writes it to storage itself, so drive access tokens never reach the browser.
  • What stays the same for storage: both modes still upload file bytes directly to storage via a presigned URL. In server mode, @useupup/server issues that URL — holding the storage credentials server-side exactly as your v1 tokenEndpoint did — and binds it to an HMAC token (a key + uploadId + size envelope).

Move to server mode when you need credential isolation, per-user token scoping, or server-side scanning/compliance. Client mode (uploadEndpoint) remains a valid, first-class choice.

@useupup/server speaks the S3 API only — set storage.endpoint for any non-AWS S3-compatible backend (MinIO, R2, DO Spaces, …). StorageProvider.Azure has no S3 surface, so createUpupHandler rejects it at construction time; use client mode with your own signing route for Azure.

See Server Mode — Setup for the Next.js, Express, Fastify, and Hono adapters, and Upload modes for choosing between them.

Re-check upload limits on the server

v1 server-side maxFileSize/accept validation silently stops applying

v1 clients sent the uploader's configured constraints along with the presign request, so a v1 route could read them back and enforce them. v3 does not: the presign body is { name, type, size, metadata } and carries no constraints at all. A v1 route ported field-for-field keeps answering 200 while validating nothing — the limits it reads off the body are now undefined, and every guard they feed passes. Nothing fails loudly, so the gap usually surfaces only when a billing or abuse cap turns out never to have been enforced.

maxFiles, maxFileSize, and allowedFileTypes are client-side UX props: they keep the picker honest, and that is all they do in either version. Anyone can POST to your presign route directly, so every limit you actually rely on has to be re-checked where the URL is signed.

With @useupup/server, flat policy is declarative and per-user policy goes in the onBeforeUpload hook. Both run on /presign and /multipart/init before any storage call:

ts
import { createUpupHandler } from '@useupup/server'

const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
    },
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    getUserId: async req => resolveUser(req),

    // Flat policy: 413 over the size cap, 415 on a disallowed type.
    maxFileSize: 25 * 1024 * 1024, // bytes — not the { size, unit } prop shape
    allowedTypes: ['image/*', 'application/pdf'],

    hooks: {
        // Per-user policy. `file` is { name, size, type }; `req` is the raw
        // Request. Returning false rejects with 403 "Upload rejected".
        onBeforeUpload: async (file, req) => {
            const plan = await resolvePlan(req)
            if (file.size > plan.perFileByteCap) return false
            if (plan.tier === 'free' && !file.type.startsWith('image/'))
                return false
            return await withinMonthlyQuota(plan, file.size)
        },
    },
})

If you kept a hand-rolled client-mode route instead, do the same work there: read name, type, and size off the request body, reject before signing, and sign for exactly the size you approved. The presigned PUT signature covers content-length, so a client that later sends a bigger body is rejected by storage rather than quietly landing an oversized object.

The server's maxFileSize is a number of bytes, not the { size, unit } object the <UpupUploader> prop takes. They are separate settings that happen to share a name — one gates the picker, the other gates the signature.

See Server Auth & Trust Model for the full policy surface, and POST /presign for the exact request shape and status codes.

Full example: before and after

v1 (upup-react-file-uploader):

tsx
'use client'

import {
    UpupUploader,
    UpupProvider,
    UploadAdapter,
} from 'upup-react-file-uploader'
import 'upup-react-file-uploader/styles'

export default function Uploader() {
    return (
        <UpupUploader
            provider={UpupProvider.AWS}
            tokenEndpoint="/api/upload-token"
            limit={5}
            accept="image/*"
            dark
            maxFileSize={{ size: 20, unit: 'MB' }}
            resumable={{ mode: 'multipart' }}
            uploadAdapters={[
                UploadAdapter.INTERNAL,
                UploadAdapter.GOOGLE_DRIVE,
            ]}
            driveConfigs={{
                googleDrive: {
                    google_client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
                    google_api_key: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!,
                    google_app_id: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!,
                },
            }}
            customProps={{ folder: 'avatars' }}
            onFileRemove={file => console.log('removed', file)}
            onFilesUploadComplete={files => console.log('done', files)}
        />
    )
}

v3 (@useupup/react):

tsx
'use client'

import { UpupUploader } from '@useupup/react'
import '@useupup/react/styles'

export default function Uploader() {
    return (
        <UpupUploader
            provider="aws"
            uploadEndpoint="/api/upload-token"
            maxFiles={5}
            allowedFileTypes="image/*"
            theme={{ mode: 'dark' }}
            maxFileSize={{ size: 20, unit: 'MB' }}
            resumable={{ protocol: 'multipart' }}
            sources={['local', 'googleDrive']}
            cloudDrives={{
                googleDrive: {
                    clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
                    apiKey: process.env.NEXT_PUBLIC_GOOGLE_API_KEY!,
                    appId: process.env.NEXT_PUBLIC_GOOGLE_APP_ID!,
                },
            }}
            metadata={{ folder: 'avatars' }}
            onFileRemoved={file => console.log('removed', file)}
            onFilesUploadComplete={files => console.log('done', files)}
        />
    )
}

Gotchas

Behavioral differences to check after the mechanical rename:

  • Your v1 server-side size/type validation silently stops applying. v3's presign body carries no constraints, so a ported v1 route keeps returning 200 while checking nothing. Re-enforce the limits where the URL is signed — see Re-check upload limits on the server.
  • sources defaults to five sources, including camera, microphone, and screen recording. Omit the prop and your app gains capture panels it never had in v1 — and recordings your allowedFileTypes may reject. Pass sources={['local', 'url']} for the v1 surface; see Sources & cloud drives.
  • maxFiles defaults to 10, not 1. v1's limit defaulted to 1. If your app relied on single-file selection, set maxFiles={1} (or use mini, which forces a single file).
  • onFilesUploadComplete always received file objects, not keys — read file.key for the storage key.
  • resumable.mode is now resumable.protocol. A leftover { mode: 'multipart' } will not enable resumable uploads.
  • onFileRemove is now onFileRemoved. The old spelling is gone (not aliased), so it silently stops firing if you miss it.
  • The panel is fixed-height by design. The full uploader is 480px tall (max-width 600px); mini is a compact square (max-width 280px). Media views (camera, screen capture, previews) adapt to that box — the panel does not grow to fit content. This is unchanged from v1's sizing model.
  • UpupProvider and UploadAdapter no longer exist. Replace enum usages with the string provider value and sources ids respectively.
  • A fresh core per mount. v3 creates and destroy()s its engine on mount/unmount; hold state in your own app or via useUpupUpload, not across a remount of <UpupUploader>.

Next steps