Upload files with Uppy, XHRUpload, and Express
Use Uppy’s Dashboard to select files and XHRUpload to send them to a multipart endpoint. This walkthrough gives you a complete local browser-to-server example: a file picker with progress and retry controls, plus an Express receiver that returns the byte count and SHA-256 of each upload.
Choose the upload contract
The Dashboard supplies the interface; XHRUpload sends one
multipart POST per file. The receiver expects the field file and answers with JSON after reading
the entire file. There is no second, handwritten XMLHttpRequest alongside Uppy.
This is a local transfer demonstration. The receiver holds each file in memory while handling its request, hashes it, and discards it. It does not save files, return download links, or inspect their contents. A successful response means the receiver read the bytes, not that it stored them durably.
Create the project
Use Bash on Linux, Node.js 24.15.0, and Yarn 4.12.0 available through corepack yarn. The example
also runs on Node.js 26.8.1; browser verification uses Chromium 145. Node runs server.ts using
built-in TypeScript stripping.
The browser code goes through esbuild.
Paste this in a directory where you want a new uppy-xhr-demo folder. The parentheses keep your
shell in its original directory. If that folder already exists, setup stops without changing it;
choose a fresh location. If installation fails, stop and resolve it before continuing.
(
mkdir uppy-xhr-demo &&
cd uppy-xhr-demo &&
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json &&
printf '%s\n' 'nodeLinker: node-modules' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact @uppy/core@6.0.2 @uppy/dashboard@6.0.0 @uppy/xhr-upload@6.0.0 express@5.2.1 multer@2.4.0 esbuild@0.27.0 &&
mkdir public
)
The empty lockfile makes this a separate Yarn project, including inside another project. Keep the
resulting yarn.lock for repeatable installs of the tested combination. The local package.json
establishes ES modules even inside a CommonJS parent project;
Yarn’s node-modules linker lets plain Node
resolve the server’s imports.
Save the following three files inside uppy-xhr-demo.
Add the receiver
Save this as server.ts. Multer parses the
multipart body and enforces request limits while receiving it. The receiver accepts one file per
request, up to 2 MiB, with no extra text fields. The MIME check only checks the sender’s
declaration; it does not prove that the bytes are a valid image or PDF.
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import express from 'express'
import multer from 'multer'
const app = express()
const receive = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 0 },
}).single('file')
const allowedTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
app.use(express.static(join(import.meta.dirname, 'public')))
app.post('/upload', (req, res) => {
receive(req, res, (error: unknown) => {
if (error) {
const tooLarge = error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE'
res.status(tooLarge ? 413 : 400).json({ error: 'Upload rejected.' })
return
}
const file = req.file
if (!file) {
res.status(400).json({ error: 'Expected one file in the file field.' })
return
}
if (!allowedTypes.has(file.mimetype)) {
res.status(415).json({ error: 'Expected a JPEG, PNG, or PDF MIME type.' })
return
}
res.json({
bytes: file.size,
sha256: createHash('sha256').update(file.buffer).digest('hex'),
})
})
})
const port = Number(process.env.PORT ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error('PORT must be an integer from 0 to 65535.')
}
const server = app.listen(port, '127.0.0.1', (error?: Error) => {
if (error) {
console.error(`Cannot start the upload server: ${error.message}`)
process.exitCode = 1
return
}
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address.')
console.log(`Open http://127.0.0.1:${address.port}`)
})
Port zero asks the operating system for an available port. You can set PORT to a fixed port if
needed. The callback handles Express 5 startup errors,
so an occupied port exits unsuccessfully instead of printing a misleading ready address.
Add the page and uploader
Save this as public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Uppy upload demo</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/app.css" />
<style>
body { margin: 1rem; font-family: sans-serif; }
#receipts { overflow-wrap: anywhere; }
</style>
<script type="module" src="/app.js"></script>
</head>
<body>
<h1>Upload to the local receiver</h1>
<div id="drag-drop-area"></div>
<h2>Received by the server</h2>
<p>These receipts confirm transfer. Files are not saved.</p>
<ul id="receipts" aria-label="Server receipts" aria-live="polite"></ul>
</body>
</html>
Save this as client.ts. The Dashboard already displays
selection errors, upload progress, cancellation, and retry controls. The small event handler adds
server receipts using textContent, so a filename is displayed as text.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import XHRUpload from '@uppy/xhr-upload'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
const receipts = document.querySelector('#receipts')
if (!(receipts instanceof HTMLUListElement)) throw new Error('Missing receipt list.')
const uppy = new Uppy({
autoProceed: false,
allowMultipleUploadBatches: false,
restrictions: {
maxFileSize: 2 * 1024 * 1024,
maxNumberOfFiles: 5,
allowedFileTypes: ['image/jpeg', 'image/png', '.pdf'],
},
})
.use(Dashboard, {
inline: true,
target: '#drag-drop-area',
note: 'Up to five JPEG, PNG, or PDF files, each up to 2 MiB.',
})
.use(XHRUpload, {
endpoint: '/upload',
fieldName: 'file',
formData: true,
bundle: false,
allowedMetaFields: false,
limit: 2,
shouldRetry: () => false,
onAfterResponse(xhr) {
if (xhr.status === 413) throw new Error('Choose a file no larger than 2 MiB.')
if (xhr.status === 415) throw new Error('The server requires a JPEG, PNG, or PDF MIME type.')
if (xhr.status < 200 || xhr.status >= 300) {
throw new Error('The server rejected the upload. Check the endpoint before retrying.')
}
},
})
uppy.on('upload-success', (file, response) => {
if (!file) return
const item = document.createElement('li')
item.textContent = `${file.name}: ${JSON.stringify(response.body)}`
receipts.append(item)
})
fieldName matches single('file') on the receiver. allowedMetaFields: false omits Uppy’s default
metadata fields to match the server’s zero-field limit. Do not set a Content-Type header yourself:
the browser supplies the multipart boundary. Two files can transfer concurrently; the remaining
files wait in Uppy’s queue.
Build and try an upload
From the same parent directory where you ran setup, build the browser bundle and start the server:
(
cd uppy-xhr-demo &&
corepack yarn exec esbuild client.ts --bundle --format=esm --outfile=public/app.js &&
node server.ts
)
esbuild emits both public/app.js and public/app.css.
The CSS needs its own link in the HTML,
which the page above includes. Rebuilding replaces those two generated files. Stop the foreground
server with Ctrl+C before rebuilding, then reload the page after restarting it.
Open the exact address printed by the server. Choose files with
browse files, then use the Dashboard’s upload button. For one
selected file, it reads Upload 1 file. Each accepted file adds a
receipt under Received by the server with bytes and sha256.
An empty PDF-named file is valid input for this transfer demo and receives bytes: 0; that does not
make it a valid PDF document.
The example takes one selection batch at a time. Once uploading starts, new selections are blocked. Retry failed files in that batch, or reload for a fresh batch after completion. Canceling clears the selection so you can choose again. Receipts already shown remain until you reload.
A progress bar reaching 100% describes the transfer, not server acceptance. Only upload-success
adds a receipt. In your own integration, do not treat Uppy’s
complete event as proof that all files succeeded: it also fires
when files failed, and supplies separate successful and failed arrays.
Keep validation on the server
Client restrictions make mistakes easier to catch before sending data. A different client can bypass them. This receiver separately limits request size and file count, but its MIME check still trusts caller-supplied metadata. It never renders, executes, or serves uploaded bytes back.
Before adapting it to a public service, add authentication and authorization, content validation for your accepted formats, and storage appropriate to your application. Apply request and concurrency limits there too: small per-file limits do not cap total memory across many simultaneous clients. The demo binds only to the local loopback address and has no authentication or persistent storage.
Troubleshooting common issues
CORS configuration
Serve the page from the printed HTTP address, not by opening index.html as a local file. The page
and /upload share an origin, so this example needs no CORS middleware. If you move the API to
another origin, configure that server to allow your actual frontend origin and required headers.
CORS controls browser access to responses;
it does not authenticate an upload request.
Network errors
This example disables automatic retries with shouldRetry: () => false to make each attempt
observable. Stop the server after loading the page and try an upload to see the Dashboard’s failure
state. Restart it on the same port, or reload at its new address and select the files again.
For a failed request on the same endpoint, fix the cause and choose Retry. XHRUpload sends that file again from the beginning. A lost response can leave the browser uncertain even if the server received the bytes. A persistent upload service needs its own duplicate-handling policy.
Use browser network throttling with a larger allowed file to try the status bar’s Cancel control while work is pending. Cancellation aborts the browser’s outstanding requests and clears the selection; it cannot retract bytes the server has already received. It is not pause/resume.
File type validation
A file larger than the client limit, an unsupported extension/type, or a sixth file produces a
Dashboard selection error without starting that file’s upload. The .pdf rule allows the filename
extension; it does not inspect PDF contents. HTTP 415 means the receiver rejected the declared MIME
type. HTTP 413 means its size limit was exceeded. Inspect the POST in the browser’s network panel;
changing CORS settings will not fix those responses.
Use tus when uploads need to resume
XHRUpload is a good fit for ordinary multipart endpoints and small files. Changing its concurrency limit does not add chunking or resumability. For uploads that must continue after interruption, use Uppy’s Tus plugin with a tus-compatible server. That is a different protocol and requires replacing this multipart receiver.
