Documentation menu

Server Mode — Setup

End-to-end setup for mode="server". In server mode the browser talks only to your server: it holds the storage credentials and the cloud-drive OAuth secrets, and it is the trust boundary every upload passes through.

This page is the hub — the config object, the routes, the limits, the hooks, and the observability seam. The per-framework mount recipes live on their own pages, linked from Framework adapters below.

Rough time budget: 15–30 minutes including provider OAuth registration.

1. Install

sh
pnpm add @upupjs/react @upupjs/server

@upupjs/server is the Node-side handler. The React package has no dependency on it — your client bundle stays free of S3 SDKs.

2. Mount the handler

The example below is the Next.js App Router; every other framework takes the same config object through a one-line adapter.

ts
// app/api/upup/[...route]/route.ts
import { createUpupHandler, InMemoryTokenStore } from '@upupjs/server'

const handler = createUpupHandler({
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
    // 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!,
    providers: {
        googleDrive: {
            clientId: process.env.GOOGLE_CLIENT_ID!,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        },
        dropbox: {
            appKey: process.env.DROPBOX_APP_KEY!,
            appSecret: process.env.DROPBOX_APP_SECRET!,
        },
        // oneDrive, box — same shape
    },
    tokenStore: new InMemoryTokenStore(), // swap for Redis in prod
    getUserId: async req => {
        // Resolve the session user. Return null → OAuth 401s.
        const session = await getSessionFromCookie(req)
        return session?.userId ?? null
    },
})

export const GET = handler
export const POST = handler

getUserId (or auth) is required once you set providers or tokenStore — it scopes tokens per user, and without it the upload routes return 403 AUTH_REQUIRED. See Server auth.

The handler routes on path suffix: /presign, /multipart/*, /auth/:provider, /auth/:provider/cb, /files/:provider, /files/:provider/transfer, /health. All paths are relative to the folder you mount it at.

Running Express, Fastify, Hono, the Pages Router, or a bare Node server instead? See Framework adapters below — same config object, one adapter call each.

3. Point the uploader at it

tsx
<UpupUploader
    mode="server"
    serverUrl="/api/upup"
    provider="aws"
    sources={['local', 'googleDrive', 'dropbox']}
/>

No cloud-drive clientId props needed on the client — the server holds them.

4. Register OAuth apps

For each drive you enable:

ProviderConsoleCallback URL
Google Driveconsole.cloud.google.com → APIs → OAuth 2.0 Client IDshttps://yourapp.com/api/upup/auth/google-drive/cb
OneDriveportal.azure.com → App registrationshttps://yourapp.com/api/upup/auth/one-drive/cb
Dropboxwww.dropbox.com/developers/appshttps://yourapp.com/api/upup/auth/dropbox/cb
Boxapp.box.com/developers/consolehttps://yourapp.com/api/upup/auth/box/cb

Scopes required:

  • Google: https://www.googleapis.com/auth/drive.readonly
  • OneDrive: Files.Read.All offline_access (offline_access is what makes Microsoft return a refresh token — without it, sessions die when the access token expires)
  • Dropbox: files.content.read files.metadata.read
  • Box: root_readonly

5. Production token store

InMemoryTokenStore is a reference implementation. Replace with any KV-shaped store for production:

ts
// Redis example
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)

const tokenStore = {
    async get(key) {
        return (await redis.get(key)) ?? null
    },
    async set(key, value, ttlSeconds) {
        if (ttlSeconds) await redis.setex(key, ttlSeconds, value)
        else await redis.set(key, value)
    },
    async delete(key) {
        await redis.del(key)
    },
}

The contract is three strings-in-strings-out methods. Cloudflare KV, DynamoDB, Postgres — anything shaped like this works.

6. Tuning

ts
createUpupHandler({
    // ...storage, uploadTokenSecret, and providers from step 2
    maxFileSize: 500 * 1024 * 1024, // 500 MB
    allowedTypes: ['image/*', 'video/*'],
    hooks: {
        onBeforeUpload: async (file, req) => {
            // Return false to reject the upload
            return true
        },
        onFileUploaded: async (file, req) => {
            // Persist a DB row pointing at file.key
        },
    },
})

The server→S3 multipart cutoff on the cloud-drive transfer path is fixed and not configurable: files up to 5 MiB stream through as a single PUT; larger files use S3 multipart with 5 MiB chunks. Server memory envelope is one chunk at a time regardless of file size — the old multipartThreshold knob was removed so the memory bound cannot be raised away by configuration.

7. Large files: the multipart flow

BrowserYour server@upupjs/serverS3-compatiblestoragePOST /multipart/initkey · uploadId · HMAC tokenPOST /multipart/sign-partpresigned URL for that partrepeated per part —the token carries key, uploadIdand the signed size envelopePUT part bytes — never through your serverPOST /multipart/completeCompleteMultipartUploadCancelled or failed runs end at POST /multipart/abort.

By default a file is uploaded whole: POST /presign hands the browser one signed URL and the bytes go straight to storage. Turn on multipart (client-side resumable: { protocol: 'multipart' }, threshold 5 MiB by default — see Resumable uploads) and the browser drives the four-step lifecycle above against your server instead:

  1. POST /multipart/init — the server starts the S3 upload and returns the object key, the uploadId, the part size, and an HMAC-signed token. The token binds the key, the uploadId, the owning user, an expiry, and a size envelope. It is the only state the client carries; the server keeps no session.
  2. POST /multipart/sign-part — one call per part, sending the token and a part number, answered with a presigned URL for exactly that part.
  3. The part PUTs go browser → storage directly. Part bytes never traverse your server on this path, so a 5 GB upload costs it nothing but the signing calls. Parts are 5 MiB by default and floored at S3's 5 MiB minimum; the browser uploads several in parallel and collects each part's ETag.
  4. POST /multipart/complete — the server sums the bytes S3 actually received, rejects and aborts the upload with 403 if the total falls outside the token's signed envelope, and only then finalizes the object. Cancelled or failed runs call POST /multipart/abort.

Two consequences worth knowing:

  • Any instance can serve any step. The signed token replaces server-side session state, so a load-balanced or serverless deployment works as long as every instance shares the same uploadTokenSecret. Different secrets across instances is the classic mid-upload failure — the /health fingerprint below catches it.
  • The token's owner is enforced, not just its signature. With getUserId configured, sign-part, complete, and abort all check that the caller is the user the token was issued to and answer 403 AUTH_DENIED otherwise. Without a getUserId resolver, possession of the token is the model.

This is a different path from the cloud-drive transfer described in Limits, where the server itself pulls bytes from a drive and pushes them to S3.

8. Re-authentication

When an OAuth access token expires, the server returns 401 { reauth: true }. The React component catches this and surfaces the provider's "Sign in" button. One click re-auths and the user continues where they left off.

Re-auth is the fallback, not the routine: when the provider issued a refresh token, the server stores it alongside the access token (as a no-expiry entry in tokenStore) and refreshes proactively before drive calls, so users rarely see the prompt. The reauth: true path fires when there is no refresh token or the refresh itself fails.

Framework adapters

createUpupHandler is a plain (req: Request) => Promise<Response> function. Every adapter wraps that same handler and takes the same config object — the one you built in step 2.

FrameworkEntry pointImport from
ExpresscreateUpupMiddleware@upupjs/server/express
FastifycreateUpupPlugin@upupjs/server/fastify
HonocreateUpupRoutes@upupjs/server/hono
Next.js — App RoutercreateUpupNextHandler@upupjs/next/server
Next.js — Pages RoutercreateUpupPagesHandler@upupjs/next/server

Each page carries the complete mount recipe for that framework plus its own body-parsing, proxy-origin, and CORS notes — those genuinely differ, and getting them wrong is the most common first-run failure.

One config, every adapter

Define the config once and share it:

ts
// lib/upup-config.ts
import type { UpupServerConfig } from '@upupjs/server'

export const upupConfig: UpupServerConfig = {
    storage: {
        type: 'aws',
        bucket: process.env.S3_BUCKET!,
        region: process.env.S3_REGION!,
        accessKeyId: process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    },
    uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
    // providers, tokenStore, getUserId… as in step 2
}

In a Next.js app you can author the same object through defineUpupConfig from @upupjs/next/server for editor autocomplete — it's a typed pass-through, not a second validation layer (required-field validation always happens inside createUpupHandler, so direct callers are protected too).

Custom Node server

Any Node framework without a dedicated adapter can reuse the same bridge the Express, Fastify, and Pages Router adapters are built on — toWebRequest and writeWebResponse from @upupjs/server/node-bridge. Don't hand-roll the conversion; the bridge already handles multi-value headers and skips content-length (Node recomputes it, and a copied value risks a mismatch).

ts
import { createServer } from 'node:http'
import { createUpupHandler } from '@upupjs/server'
import { toWebRequest, writeWebResponse } from '@upupjs/server/node-bridge'
import { upupConfig } from './lib/upup-config'

const handler = createUpupHandler(upupConfig)

createServer(async (req, res) => {
    const chunks: Buffer[] = []
    for await (const chunk of req) chunks.push(Buffer.from(chunk))

    const webReq = toWebRequest({
        url: new URL(req.url ?? '/', `http://${req.headers.host}`).toString(),
        method: req.method ?? 'GET',
        headers: req.headers,
        // toWebRequest drops the body for GET/HEAD on its own.
        body: chunks.length ? Buffer.concat(chunks) : undefined,
    })

    await writeWebResponse(
        {
            status: code => {
                res.statusCode = code
            },
            setHeader: (name, value) => {
                res.setHeader(name, value)
            },
            send: body => {
                res.end(body)
            },
        },
        await handler(webReq),
    )
}).listen(3000)

The sink is three methods — status, setHeader, send — so Express's res, Fastify's reply, and NextApiResponse all satisfy it with a thin rename.

Lifecycle hooks

Three optional hooks let you gate uploads and react to completions:

ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    hooks: {
        onBeforeUpload: async (file, req) => true, // false rejects with 403
        onFileUploaded: async (file, req) => {
            // one file finished — file.key, .name, .size, .type, .url
        },
        onUploadComplete: async (files, req) => {
            // a request's file(s) finished
        },
    },
})

Which hook fires on which path. Read this before wiring alerting, billing, or webhooks on top of them — the gaps are structural, not bugs:

RouteonBeforeUploadonFileUploadedonUploadComplete
POST /presignyesnono
POST /multipart/inityesnono
POST /multipart/completenoyesyes
POST /files/:provider/transfernoyesno
  • onBeforeUpload is an admission gate, not a completion signal. It runs during metadata validation on /presign and /multipart/init, after the maxFileSize and allowedTypes checks. Returning false responds 403 Upload rejected and nothing is presigned.
  • onFileUploaded fires once per file on the two server-side-completion paths only: /multipart/complete (the server just finished the S3 multipart upload) and /files/:provider/transfer (the server just finished streaming a cloud-drive file into S3). In both cases the server can actually see the finished object.
  • onUploadComplete fires only on /multipart/complete, always with a single-element array. The server completes one file per request and has no cross-file batching concept. For a true "the whole batch is done" signal, use the client-side onUploadComplete prop instead — that one sees the entire selection.
  • Client-direct presigned PUTs fire no server hook at all. POST /presign only hands the browser a URL; the bytes then go straight to S3 and the server never observes completion. If you need server visibility into that path, use the client-side onUploadComplete prop, or point processingEndpoint at an SSE route so the client tells your server when it's done.
  • On the multipart-complete path, file.type is always '' — the declared MIME type isn't retained server-side once the upload completes.

A hook that throws after a successful upload is reported through onError and swallowed, never re-coded as a 500. The object is already durably in S3, so a 500 would only tell the client to retry something that already succeeded.

Observability

The onError seam

Every error path in the handler — 500s, invalid upload tokens, OAuth and token-exchange failures, failed drive-transfer aborts, health-check storage failures — routes through one logger:

ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    onError: event => {
        // event: route, method, status, code, message, requestId,
        //        error: { name, message, stack }
        myLogger.error('upup-server', event)
    },
})

If you don't supply onError, the default writes one structured line via console.error('[upup:server]', JSON.stringify(event)) — error visibility is on out of the box, not something you wire up after your first incident. Pass a no-op to silence it.

Redaction guarantee. An error event is only ever built from a static route string, the request method, the HTTP status, a machine code, a generic message, and the caught error's name / message / stack. Request bodies, drive tokens, uploadTokenSecret, S3 credentials, signatures, and Authorization headers are never put into an event.

That contract is enforced structurally, not by convention: every event passes through a scrubber at the single reporting seam before it reaches your logger, so even an error whose message accidentally interpolated a credential is cleaned. It rewrites Authorization header dumps, bare Bearer tokens, the SigV4 x-amz-signature / x-amz-credential / x-amz-security-token params, and AWS access-key ids to [REDACTED]. It's deliberately conservative — ordinary stack frames, file paths, and function names survive intact. Treat it as defense in depth, not licence to build an event out of a secret.

Request IDs

Every response the handler produces carries an x-upup-request-id header, and the same value appears as event.requestId in your onError events. That's the join key between a client-reported failure and your server logs — health responses included; there are no exceptions to the contract.

The /health endpoint

sh
curl https://yourapp.com/api/upup/health
json
{
    "status": "ok",
    "checks": { "config": "ok", "storage": "ok" },
    "summary": {
        "storageType": "aws",
        "anonymousUploads": false,
        "anonymousDrives": false,
        "driveProviders": 2,
        "uploadTokenTtlSeconds": 3600
    }
}

It's unauthenticated by design — the check runs before config.auth, so uptime and deploy probes work without credentials — and always responds 200. The status field carries the real signal, so an orchestrator won't restart a container over a transient S3 blip.

  • checks.config is ok when storage.bucket, storage.region, and a valid-length uploadTokenSecret are all present.
  • checks.storage is a cheap bucket-head probe (no listing, no transfer), cached for 30 seconds so repeated polling doesn't hammer S3. A failed probe is also reported through onError.
  • summary is labels, flags, and counts only — never a secret value.

To catch cross-instance secret drift after a rolling deploy, opt into a fingerprint:

ts
createUpupHandler({
    // ...storage, uploadTokenSecret
    health: { exposeSecretFingerprint: true },
})

That adds uploadTokenFingerprint — the first 8 hex characters of SHA-256(uploadTokenSecret). Two instances showing different fingerprints are running different secrets, which breaks multipart uploads that start on one and continue on another. It's a one-way hash, not the secret. Default is off.

Full request/response shapes for every route: Server HTTP API. For client-side error wiring, see Error monitoring.

Limits

maxFileSize

Enforced on both upload paths, and on the drive path it's enforced twice:

  • POST /presign and POST /multipart/init reject a declared size over the limit with 413 File too large before anything is signed.
  • POST /files/:provider/transfer fast-rejects the drive-declared size with 413, then enforces the cap again against the bytes actually streamed. A file that lies about its size is aborted mid-transfer and leaves nothing behind in the bucket — the streamed-byte check is the authoritative one.

allowedTypes

An allowlist of MIME types; 415 File type not allowed otherwise. One shared policy across the upload and drive-transfer paths:

  • Omitted or empty → every type passes.
  • An image/* entry honours the wildcard.
  • An absent or empty type does not match a non-empty allowlist. A file with no declared MIME type is rejected, never silently waved through.

These are server-side policy. The client-side maxFileSize / file-type props give the user a fast local error; the server checks are what actually hold, since a client can be bypassed.

The drive-transfer memory bound

When the server pulls a file out of a cloud drive and pushes it to S3, its memory envelope is fixed at 5 MiB regardless of file size. A file whose size the drive reports as 5 MiB or less goes through as a single PUT (one buffered body); everything else — including anything the drive reports no size for — streams through bounded 5 MiB multipart parts, one part in memory at a time.

This cutoff is not configurable, deliberately. The old multipartThreshold knob was removed because raising it reintroduced unbounded buffering — a memory-safety bound must not be something an integrator can raise away. There is no replacement setting; a 5 GB drive file and a 5 MB one cost the server the same memory.