server-mode-setup.mdx
September 2, 2026 · View on GitHub
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
pnpm add @useupup/react @useupup/server
@useupup/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.
// app/api/upup/[...route]/route.ts
import { createUpupHandler, InMemoryTokenStore } from '@useupup/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
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
<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:
| Provider | Console | Callback URL |
|---|---|---|
| Google Drive | console.cloud.google.com → APIs → OAuth 2.0 Client IDs | https://yourapp.com/api/upup/auth/google-drive/cb |
| OneDrive | portal.azure.com → App registrations | https://yourapp.com/api/upup/auth/one-drive/cb |
| Dropbox | www.dropbox.com/developers/apps | https://yourapp.com/api/upup/auth/dropbox/cb |
| Box | app.box.com/developers/console | https://yourapp.com/api/upup/auth/box/cb |
Scopes required:
- Google:
https://www.googleapis.com/auth/drive.readonly - OneDrive:
Files.Read.All offline_access(offline_accessis 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:
// 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
createUpupHandler({
// ...storage, uploadTokenSecret, and providers from step 2
maxFileSize: 500 * 1024 * 1024, // 500 MB
allowedTypes: ['image/*', 'video/*'],
multipartResumeWindowSeconds: 86400, // 24h; 0 disables POST /multipart/resume
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
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 lifecycle above against your server instead. The diagram
shows the happy path; steps 4 and 5 below cover finishing and recovering:
POST /multipart/init— the server starts the S3 upload and returns the object key, theuploadId, the part size, and an HMAC-signed token. The token binds the key, theuploadId, the owning user, an expiry, and a size envelope. It is the only state the client carries; the server keeps no session.POST /multipart/sign-part— one call per part, sending the token and a part number, answered with a presigned URL for exactly that part.- 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'sETag. POST /multipart/complete— the server sums the bytes S3 actually received, rejects and aborts the upload with403if the total falls outside the token's signed envelope, and only then finalizes the object. An explicit cancel callsPOST /multipart/abort.POST /multipart/resume— the recovery step. The browser presents the token it saved, and the server answers with the parts storage already holds (each with its byte size) plus a freshly-signed token. That is how an upload survives a reload or a tab close, and how a token that outlived its one-hour expiry is refreshed mid-upload. Its window ismultipartResumeWindowSeconds(24 hours by default, measured from the originalinit); set it to0to switch the route off entirely.
Configure an `AbortIncompleteMultipartUpload` lifecycle rule on the bucket
with a 1–7 day expiry. Every S3-compatible provider supports it, MinIO
included. See
[Cross-reload resume](/docs/resumable-uploads/#cross-reload-resume).
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/healthfingerprint below catches it. - The token's owner is enforced, not just its signature. With
getUserIdconfigured,sign-part,complete,abort, andresumeall check that the caller is the user the token was issued to and answer403 AUTH_DENIEDotherwise. Without agetUserIdresolver, possession of the token is the model. resumeis the one route that tolerates an expired token, since refreshing one is its job. It applies the resume window instead, anchored at the originalinitand carried forward on every re-issue, so the usable life of a leaked token is bounded even across a chain of resumes. See Server Auth & Trust 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.
| Framework | Entry point | Import from |
|---|---|---|
| Express | createUpupMiddleware | @useupup/server/express |
| Fastify | createUpupPlugin | @useupup/server/fastify |
| Hono | createUpupRoutes | @useupup/server/hono |
| Next.js — App Router | createUpupNextHandler | @useupup/next/server |
| Next.js — Pages Router | createUpupPagesHandler | @useupup/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:
// lib/upup-config.ts
import type { UpupServerConfig } from '@useupup/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 @useupup/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 @useupup/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).
import { createServer } from 'node:http'
import { createUpupHandler } from '@useupup/server'
import { toWebRequest, writeWebResponse } from '@useupup/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
Four optional hooks let you gate uploads, rewrite what the server hands back, and react to completions:
createUpupHandler({
// ...storage, uploadTokenSecret
hooks: {
onBeforeUpload: async (file, req) => true, // false rejects with 403
onPresignResponse: (response, ctx) => {
// return an object to replace the payload; nothing to keep it
},
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:
| Route | onBeforeUpload | onPresignResponse | onFileUploaded | onUploadComplete |
|---|---|---|---|---|
POST /presign | yes | yes | no | no |
POST /multipart/init | yes | yes | no | no |
POST /multipart/sign-part | no | yes | no | no |
POST /multipart/complete | no | no | yes | yes |
POST /files/:provider/transfer | no | no | yes | no |
onBeforeUploadis an admission gate, not a completion signal. It runs during metadata validation on/presignand/multipart/init, after themaxFileSizeandallowedTypeschecks. Returningfalseresponds403 Upload rejectedand nothing is presigned. To explain the rejection, throw anUpupErrorinstead — see Explaining a rejection below.onPresignResponseis the only hook that can change a response body. It sees the three presign-side payloads and nothing else — see Rewriting presign responses.onFileUploadedfires 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.onUploadCompletefires 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-sideonUploadCompleteprop instead — that one sees the entire selection.- Client-direct presigned PUTs fire no server hook at all.
POST /presignonly 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-sideonUploadCompleteprop, or pointprocessingEndpointat an SSE route so the client tells your server when it's done. - On the multipart-complete path,
file.typeis 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.
Rewriting presign responses
If your bucket is not reachable from the browser — a private MinIO behind a
same-origin proxy route, a docker-internal hostname in local dev, a VPC-only
endpoint — the signed URL the server produces is not the URL the browser can
use. onPresignResponse gets the last look at the payload and can replace it:
createUpupHandler({
// ...storage, uploadTokenSecret
hooks: {
onPresignResponse: (response, ctx) => {
if (!('uploadUrl' in response)) return
return {
...response,
uploadUrl: response.uploadUrl.replace(
'https://minio.internal:9000',
'https://app.example.com/api/s3',
),
}
},
},
})
Return an object to replace the payload; return nothing to leave it alone.
It fires on exactly three responses, told apart by ctx.phase:
ctx.phase | Route | Payload |
|---|---|---|
presign | POST /presign | PresignedUrlResponse |
multipart-init | POST /multipart/init | MultipartInitResponse plus the token |
multipart-sign-part | POST /multipart/sign-part | MultipartSignPartResponse |
ctx also carries req, the server-chosen key (always the key in the
payload), the resolved userId, and metadata — the client-declared file
metadata, absent on multipart-sign-part, which sees only a verified token and
a part number.
Cover all three phases if you use multipart. Rewriting only /presign leaves
multipart uploads pointed at the unreachable host.
Explaining a rejection
onBeforeUpload returning false answers a deliberately generic
403 { "error": "Upload rejected" } — it says nothing about why. When the
reason is something the user should see, throw an UpupError instead and its
message and code are serialized into the 403 body:
import { UpupQuotaError } from '@useupup/core'
hooks: {
onBeforeUpload: async (file, req) => {
const { used, limit } = await getQuota(req)
if (used + file.size > limit) {
throw new UpupQuotaError(
'Storage limit exceeded — upgrade to keep uploading',
limit,
used,
)
}
return true
}
}
{
"error": "Storage limit exceeded — upgrade to keep uploading",
"code": "QUOTA_EXCEEDED"
}
Only UpupError and its subclasses are serialized, and only from this hook.
Any other throw stays a generic 500 with the real cause going to onError
alone — an accidental Error carrying a connection string or a stack trace
never reaches the client.
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:
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
curl https://yourapp.com/api/upup/health
{
"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.configisokwhenstorage.bucket,storage.region, and a valid-lengthuploadTokenSecretare all present.checks.storageis 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 throughonError.summaryis labels, flags, and counts only — never a secret value.
To catch cross-instance secret drift after a rolling deploy, opt into a fingerprint:
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.
Multi-bucket routing
storage takes either one static object or a resolver called per request,
for apps that split uploads across buckets — images in one, unscanned documents
in a quarantine bucket, each tenant in their own:
import { defineUpupConfig } from '@useupup/next/server'
const BUCKETS = {
images: { type: 'aws', bucket: 'app-images', region: 'us-east-1' },
quarantine: { type: 'aws', bucket: 'app-quarantine', region: 'us-east-1' },
documents: { type: 'aws', bucket: 'app-docs', region: 'eu-west-1' },
} as const
export default defineUpupConfig({
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET,
storage: ctx => {
// A continuation of a multipart upload: return the bucket it started in.
if (ctx.storageId) return byIdentity(ctx.storageId)
const requested = ctx.metadata?.uploadClass
if (requested === 'images') return BUCKETS.images
if (requested === 'unscanned') return BUCKETS.quarantine
return BUCKETS.documents
},
})
The client picks a class by sending a metadata object alongside the file's
name, type, and size in the presign body. ctx carries req, phase,
userId, metadata, fileName, contentType, size — and storageId on
multipart continuations.
How multipart stays in one bucket
A multipart upload resolves its bucket once, at init — but sign-part,
complete, and abort arrive later carrying only a token, with none of the
metadata that decision was made from. upup closes that gap by stamping an
opaque storage identity into the HMAC-signed upload token at init and
handing it back to your resolver as ctx.storageId.
Return the storage matching that id and the upload proceeds. The server then
re-derives the identity of whatever you returned and answers
403 AUTH_DENIED on a mismatch, so a resolver that ignores storageId fails
loudly instead of writing parts into the wrong bucket. The client never supplies
the identity unsigned, and it is a hash of bucket, endpoint, and region only —
never of your credentials, so rotating an access key does not strand uploads in
flight.
A token issued while storage was still a static object carries no identity;
if you then switch to a resolver, uploads already in flight answer 403 and
restart from init. Upload tokens live one hour.
What changes with a resolver
- Validation moves to request time. A static config is checked when
createUpupHandleris constructed; a resolver has nothing to check until a request arrives, so a bad result fails that one request with a500and the cause goes toonError. There is no fallback bucket — a misrouted write is worse than a failed one. /healthreportsstorage: "skipped"andstorageType: "dynamic". There is no single destination to probe, and calling your resolver from an unauthenticated liveness route would reach a real backend.- S3 clients are cached per destination, keyed on endpoint, region, bucket, and access key ID, so a multi-bucket deployment does not rebuild a client and its connection pool on every presign.
keyStrategy sees it too
keyStrategy now receives metadata and req alongside the existing
userId / fileName / contentType / size — existing strategies keep
working unchanged:
keyStrategy: ctx =>
`${ctx.metadata?.tenant ?? 'shared'}/${ctx.userId ?? 'anon'}/${crypto.randomUUID()}`
The same warning applies: ctx.metadata is untrusted, and here it lands in an
object key. Sanitize anything you interpolate.
Download URLs
Every route that reports a stored object hands back a presigned GET —
downloadUrl on /presign and /multipart/complete, url on
/files/:provider/transfer. Those links expire after 3 days by default.
downloadUrlExpiresIn
One knob sets the TTL, in seconds, for every signed GET the server issues:
createUpupHandler({
// ...storage, uploadTokenSecret
downloadUrlExpiresIn: 900, // 15 minutes
})
It covers the download half only. The upload URL's own 1-hour expiry is separate and unaffected, so shortening download links never shortens the window a large upload has to finish in.
Gated downloads: getDownloadUrl
For content you serve later — a link on a dashboard, an email attachment, a
paywalled asset — you need a fresh URL for a key stored weeks ago, with no
upload in flight. getDownloadUrl is that operation on its own, with no
handler and no HTTP route involved:
import { getDownloadUrl } from '@useupup/server'
import { upupConfig } from './lib/upup-config'
const url = await getDownloadUrl(upupConfig, invoice.storageKey, {
expiresIn: 300, // 5 minutes, for this link only
})
The first argument is your server config, or any object with a storage
slice — it reuses the same credentials and endpoint the handler uses. Expiry
resolves in this order:
- the per-call
expiresIn, config.downloadUrlExpiresIn,- the 3-day default.
Two things to keep in mind:
- It authorizes nothing. It signs whatever key you hand it. Check that the
current user may see that object before you call it — this is the same
trust position as
S3Client.getSignedUrl, not a replacement for your access control. - S3-compatible providers only, like the rest of the package. A
storage.typewith no S3 API (azure) throwsUpupConfigError, the same errorcreateUpupHandlerthrows at construct time.
Limits
maxFileSize
Enforced on both upload paths, and on the drive path it's enforced twice:
POST /presignandPOST /multipart/initreject a declared size over the limit with413 File too largebefore anything is signed.POST /files/:provider/transferfast-rejects the drive-declared size with413, 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.