Almost every React app eventually needs an upload form: avatars, invoices, ID documents, CSV imports. And almost every tutorial on React Node.js file upload with Multer stops at the same place: a basic form, multer({ dest: 'uploads/' }), and a happy “File uploaded!” message. dev.to has covered this at length.
That code works on localhost. In production it is a liability. It accepts .exe files renamed to .png, writes attacker-controlled filenames straight to disk, fills the server volume when someone uploads a 4 GB video, and gives the browser zero feedback while a large file crawls up a mobile connection.
This guide builds the whole feature end to end, then fixes everything the shorter tutorials leave out.
What you will build
- A React drag-and-drop uploader with a real progress bar and a cancel button (no third-party UI library required)
- An Express + Multer endpoint with disk storage, size limits, file count limits and strict MIME validation
- Filename sanitizing and UUID-based storage names to kill path traversal and overwrite attacks
- Magic-byte verification so a renamed executable is rejected even when the declared MIME type looks fine
- A clean Multer error handler that returns useful JSON instead of an HTML stack trace
- A path to stream large files to S3 instead of parking them on local disk
Versions used in this tutorial
| Package | Version | Notes |
|---|---|---|
| Node.js | 22 LTS or 24 | Native crypto.randomUUID() and ESM |
| Express | 5.x | Async errors propagate automatically |
| Multer | 2.x | Do not use 1.4.4 or older, it carries known advisories |
| React | 19.x | Vite or Next.js client component both fine |

Why Multer, and how it actually works
Browsers send file uploads as multipart/form-data. Express does not parse that format: express.json() and express.urlencoded() both ignore it, which is why req.body and req.file come back undefined for beginners.
Multer is Node.js middleware built on top of busboy. It streams the multipart body, hands each part to a storage engine, and then populates:
req.fileforupload.single('field')req.filesforupload.array('field', max)orupload.fields([...])req.bodyfor the non-file text fields sent alongside
Three storage engines matter in practice:
| Engine | Where bytes go | Use it when |
|---|---|---|
diskStorage |
Local filesystem | Single server, files processed then moved |
memoryStorage |
RAM as a Buffer | Small files only (thumbnails, CSV under a few MB) |
multer-s3 |
S3 / R2 / MinIO | Multiple instances, containers, large media |
Never use memoryStorage as a default. Ten concurrent 200 MB uploads is 2 GB of heap, and a single container will be OOM-killed long before that.
Step 1: the Express + Multer backend
Install
mkdir upload-api && cd upload-api
npm init -y
npm i express multer cors file-type express-rate-limit
Add "type": "module" to package.json so the ESM imports below work.
The Multer configuration
Here is the core of it. Read the comments, because each one blocks a specific attack.
// upload.js
import multer from "multer";
import path from "node:path";
import crypto from "node:crypto";
import fs from "node:fs";
const UPLOAD_DIR = path.resolve("storage/uploads");
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Allowlist, never a denylist. Map MIME -> the extension WE choose.
const ALLOWED_TYPES = new Map([
["image/jpeg", ".jpg"],
["image/png", ".png"],
["image/webp", ".webp"],
["application/pdf", ".pdf"]
]);
const storage = multer.diskStorage({
destination(req, file, cb) {
cb(null, UPLOAD_DIR);
},
filename(req, file, cb) {
// The original name is attacker input. We do not reuse it on disk.
const ext = ALLOWED_TYPES.get(file.mimetype) ?? "";
cb(null, crypto.randomUUID() + ext);
}
});
function fileFilter(req, file, cb) {
if (!ALLOWED_TYPES.has(file.mimetype)) {
const err = new multer.MulterError("LIMIT_UNEXPECTED_FILE", file.fieldname);
err.message = `Unsupported file type: ${file.mimetype}`;
return cb(err);
}
cb(null, true);
}
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB per file
files: 5, // max 5 files per request
fields: 10, // max 10 text fields
parts: 20, // total parts guard
fieldNameSize: 100, // stops absurd field names
headerPairs: 200
}
});
The route, with magic-byte verification
file.mimetype comes from the Content-Type header the client sent. It is a hint, not a fact. Anyone with curl can claim a shell script is image/png. So after Multer has written the file, we inspect the real bytes.
// server.js
import express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import rateLimit from "express-rate-limit";
import { fileTypeFromFile } from "file-type";
import { upload } from "./upload.js";
const app = express();
app.use(cors({ origin: "http://localhost:5173" }));
const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 });
const SAFE_SNIFFED = new Set(["jpg", "png", "webp", "pdf"]);
app.post("/api/uploads", uploadLimiter, upload.array("files", 5), async (req, res, next) => {
try {
if (!req.files?.length) {
return res.status(400).json({ error: "No files received" });
}
const accepted = [];
for (const file of req.files) {
const sniffed = await fileTypeFromFile(file.path);
if (!sniffed || !SAFE_SNIFFED.has(sniffed.ext)) {
await fs.unlink(file.path); // delete the impostor immediately
continue;
}
accepted.push({
id: file.filename,
size: file.size,
type: sniffed.mime,
// keep the display name in the DB, never on disk
displayName: sanitizeDisplayName(file.originalname)
});
}
if (!accepted.length) {
return res.status(415).json({ error: "File content did not match an allowed type" });
}
res.status(201).json({ files: accepted });
} catch (err) {
next(err);
}
});
function sanitizeDisplayName(name = "") {
return path
.basename(name) // strips ../../ and C:\ prefixes
.replace(/[^\w.\- ]+/g, "_") // drops control chars, quotes, RTL overrides
.slice(0, 120);
}
A Multer error handler that returns real JSON
Without this, a file that is 1 byte over the limit produces a 500 with an HTML body, and your React catch block shows “Unexpected token <“.
import multer from "multer";
const MULTER_MESSAGES = {
LIMIT_FILE_SIZE: "File is larger than the 10 MB limit",
LIMIT_FILE_COUNT: "Too many files, 5 maximum",
LIMIT_UNEXPECTED_FILE: "Unexpected or unsupported file",
LIMIT_PART_COUNT: "Too many parts in the request"
};
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(413).json({
code: err.code,
error: err.message || MULTER_MESSAGES[err.code] || "Upload rejected"
});
}
console.error(err);
res.status(500).json({ error: "Upload failed" });
});
app.listen(4000, () => console.log("Upload API on :4000"));
Quick sanity check with curl
curl -F "[email protected]" http://localhost:4000/api/uploads
curl -F "[email protected];type=image/png" http://localhost:4000/api/uploads
The first returns 201. The second passes fileFilter (the header lies) but is destroyed by the magic-byte check and returns 415. That second request is exactly what most tutorials accept.
Step 2: the React drag-and-drop uploader
Why XMLHttpRequest and not fetch
This surprises people: fetch() still has no portable upload progress event. Request streams exist but require HTTP/2, duplex mode and are not supported everywhere. For a reliable progress bar in 2026, XMLHttpRequest.upload.onprogress remains the pragmatic choice. It also gives you xhr.abort() for free.
The component
// FileUploader.jsx
import { useRef, useState } from "react";
const MAX_BYTES = 10 * 1024 * 1024;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
export default function FileUploader() {
const [queue, setQueue] = useState([]);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("idle");
const [message, setMessage] = useState("");
const [dragging, setDragging] = useState(false);
const inputRef = useRef(null);
const xhrRef = useRef(null);
function addFiles(fileList) {
const incoming = Array.from(fileList);
const valid = [];
const errors = [];
for (const file of incoming) {
if (!ACCEPTED.includes(file.type)) {
errors.push(`${file.name}: type not allowed`);
} else if (file.size > MAX_BYTES) {
errors.push(`${file.name}: over 10 MB`);
} else {
valid.push(file);
}
}
setMessage(errors.join(" / "));
setQueue((prev) => [...prev, ...valid].slice(0, 5));
}
function onDrop(e) {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}
function removeAt(index) {
setQueue((prev) => prev.filter((_, i) => i !== index));
}
function startUpload() {
if (!queue.length) return;
const form = new FormData();
queue.forEach((file) => form.append("files", file));
form.append("folder", "invoices"); // extra fields land in req.body
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.open("POST", "http://localhost:4000/api/uploads");
xhr.withCredentials = true;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
setProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
let payload = {};
try { payload = JSON.parse(xhr.responseText); } catch {}
if (xhr.status >= 200 && xhr.status < 300) {
setStatus("done");
setMessage(`${payload.files?.length ?? 0} file(s) stored`);
setQueue([]);
} else {
setStatus("error");
setMessage(payload.error || `Upload failed (${xhr.status})`);
}
};
xhr.onerror = () => { setStatus("error"); setMessage("Network error"); };
xhr.onabort = () => { setStatus("idle"); setProgress(0); setMessage("Cancelled"); };
setStatus("uploading");
setProgress(0);
xhr.send(form); // no Content-Type header, the browser sets the boundary
}
return (
<div className="uploader">
<div
className={dragging ? "dropzone dropzone--active" : "dropzone"}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
role="button"
tabIndex={0}
>
<p>Drag files here, or click to browse</p>
<small>JPG, PNG, WEBP, PDF. 10 MB max, 5 files max.</small>
<input
ref={inputRef}
type="file"
multiple
hidden
accept={ACCEPTED.join(",")}
onChange={(e) => addFiles(e.target.files)}
/>
</div>
<ul>
{queue.map((file, i) => (
<li key={file.name + i}>
{file.name} ({(file.size / 1024).toFixed(0)} KB)
<button type="button" onClick={() => removeAt(i)}>remove</button>
</li>
))}
</ul>
{status === "uploading" && (
<>
<progress value={progress} max="100" /> <span>{progress}%</span>
<button type="button" onClick={() => xhrRef.current?.abort()}>Cancel</button>
</>
)}
<button type="button" disabled={!queue.length || status === "uploading"} onClick={startUpload}>
Upload
</button>
{message && <p className={status === "error" ? "msg msg--error" : "msg"}>{message}</p>}
</div>
);
}
Three React mistakes that cost hours
- Setting the Content-Type header manually. If you write
"Content-Type": "multipart/form-data", the boundary token is missing and Multer sees an empty body. Let the browser set it. - The field name must match.
form.append("files", file)in React has to matchupload.array("files")in Express, otherwise Multer throwsLIMIT_UNEXPECTED_FILE. - Appending text fields after the files. Multer parses parts in order, so a field appended after the files is not yet in
req.bodyinsidediskStorage.filename(). If your storage path depends on a field, append that field first.

Step 3: the security pitfalls most tutorials skip
This is the part that separates a demo from something you can ship.
1. Never write the original filename to disk
file.originalname can contain ../../etc/cron.d/x, a null byte, a 400-character name, a Unicode right-to-left override that makes gpj.exe render as exe.jpg, or simply the same name another user already uploaded. Generate the stored name yourself with crypto.randomUUID() and keep the human-readable name in your database. If you truly must reuse it, run it through path.basename() plus a strict character allowlist. Uploading files with ReactJS and NodeJS tackles the same question from another angle.
2. Derive the extension from your allowlist, not from the upload
Do not do path.extname(file.originalname). That happily writes .php, .jsp, .sh, .html or the classic double extension invoice.pdf.exe. Map the validated type to a fixed extension you control, as in the ALLOWED_TYPES map above.
3. Validate content, not headers
Multer’s fileFilter only sees the declared MIME type. Sniff the magic bytes afterwards with file-type, and for images consider re-encoding through sharp, which both strips EXIF metadata and destroys any polyglot payload:
import sharp from "sharp";
await sharp(file.path).rotate().resize({ width: 2000, withoutEnlargement: true })
.toFormat("webp", { quality: 82 }).toFile(`${file.path}.webp`);
4. Treat SVG as executable code
An SVG is XML and can carry <script>, event handlers and external references. Served from your own origin, it is stored XSS. Either exclude SVG entirely, or sanitize it server side with DOMPurify in JSDOM before storing it.
5. Store uploads outside the web root
Do not point express.static() at your upload directory. Serve files through a controller that checks authorization and sets defensive headers:
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Content-Disposition", `attachment; filename="${safeName}"`);
6. Enforce limits in three places
| Layer | Control | Why |
|---|---|---|
| Reverse proxy | client_max_body_size 12m; in Nginx |
Rejects the body before it reaches Node |
| Multer | limits.fileSize, files, parts |
Aborts the stream mid-flight |
| React | file.size check and accept |
UX only, trivially bypassed |
7. Clean up and rate limit
- When validation fails after Multer wrote the file,
fs.unlinkit. Otherwise rejected junk accumulates forever. - Handle
req.on("aborted")to delete partial files from cancelled uploads. - Authenticate before Multer runs. Put your auth middleware before
upload.single()so anonymous requests never get to write bytes. - Rate limit the endpoint per user, and run an antivirus pass (ClamAV via
clamscan) if you accept documents from the public.
Step 4: streaming large files to S3 instead of local disk
Local disk breaks the moment you run two containers: the file is on instance A, the next request hits instance B. There are two good patterns.
Option A: multer-s3 (proxy through your API)
Bytes stream from the request straight to S3 without ever hitting the filesystem or buffering in RAM.
npm i multer-s3 @aws-sdk/client-s3
import { S3Client } from "@aws-sdk/client-s3";
import multerS3 from "multer-s3";
import multer from "multer";
import crypto from "node:crypto";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export const uploadToS3 = multer({
storage: multerS3({
s3,
bucket: process.env.S3_BUCKET,
// no ACL: keep the bucket private and serve via signed URLs
contentType: multerS3.AUTO_CONTENT_TYPE,
key(req, file, cb) {
const ext = ALLOWED_TYPES.get(file.mimetype) ?? "";
cb(null, `u/${req.user.id}/${crypto.randomUUID()}${ext}`);
},
metadata(req, file, cb) {
cb(null, { original: encodeURIComponent(file.originalname) });
}
}),
fileFilter,
limits: { fileSize: 50 * 1024 * 1024, files: 5 }
});
Keep the bucket private, block public access at the account level, and generate short-lived GetObject signed URLs for reads. Note the trade-off: with this pattern you cannot magic-byte check before the object exists, so validate after the fact and delete on failure.
Option B: presigned uploads (browser talks to S3 directly)
For files above roughly 100 MB, or on serverless platforms with request size and duration caps, stop proxying. Your API only issues a signed instruction; the browser does the transfer.
- React asks
POST /api/uploads/signwith the filename, size and type. - The API authorizes the user, validates size and type, generates a key, and returns a presigned POST with policy conditions (
content-length-range,starts-withon the key, allowed content type). - React uploads to S3 with
XMLHttpRequestso you keep the same progress bar. - An S3 event notification or a client confirmation call triggers validation, virus scanning and thumbnail generation, then marks the record as ready.
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
const { url, fields } = await createPresignedPost(s3, {
Bucket: process.env.S3_BUCKET,
Key: `u/${req.user.id}/${crypto.randomUUID()}.jpg`,
Conditions: [
["content-length-range", 1, 50 * 1024 * 1024],
["eq", "$Content-Type", "image/jpeg"]
],
Expires: 60
});
Choosing between them
| Criterion | Multer disk | multer-s3 | Presigned |
|---|---|---|---|
| Setup effort | Low | Medium | Higher |
| Horizontal scaling | No | Yes | Yes |
| Server bandwidth cost | High | High | None |
| Validate before storing | Yes | Partial | After upload |
| Good up to | ~25 MB | ~100 MB | Multi-GB |
Production checklist
- Multer 2.x installed,
npm auditclean - Auth and rate limiting run before the Multer middleware
limitsset forfileSize,files,fieldsandparts- MIME allowlist in
fileFilterplus magic-byte verification after write - Stored filenames are UUIDs with a server-chosen extension
- Original filename sanitized and kept in the database only
- Upload directory outside the web root, or a private bucket
- Downloads served with
nosniff, a sandbox CSP andContent-Disposition: attachment - Rejected, partial and cancelled uploads are deleted
- Proxy body-size limit aligned with the Multer limit
- MulterError handler returning JSON, with matching React messages
- Images re-encoded to strip metadata, SVG excluded or sanitized
FAQ
How do I use Multer to upload a file in Node.js?
Install it with npm install multer, create an instance with a storage engine and limits, then attach it as route middleware: app.post("/api/uploads", upload.single("file"), handler). Multer parses the multipart/form-data body and exposes the result on req.file, with any text fields on req.body.
Why is req.file undefined in my Express route?
Four usual causes: the React field name does not match the name passed to single() or array(); you set a manual Content-Type header and killed the multipart boundary; you used upload.array() and should read req.files; or another body parser consumed the stream before Multer.
How do I upload multiple files with React and Multer?
Add multiple to the input, append every file under the same key with FormData.append("files", file), and use upload.array("files", 5) server side. For different fields such as an avatar and a document together, use upload.fields([{ name: "avatar", maxCount: 1 }, { name: "doc", maxCount: 3 }]).
Can I show upload progress with fetch instead of XMLHttpRequest?
Not reliably yet. Request-body streaming with duplex: "half" requires HTTP/2 and is not supported across all browsers, so XMLHttpRequest.upload.onprogress is still the dependable way to drive a progress bar, and it gives you cancellation through abort().
Is checking the MIME type in fileFilter enough?
No. The MIME type is supplied by the client and can be forged in one curl flag. Combine an allowlist in fileFilter with magic-byte inspection after the write, a server-chosen file extension, and ideally re-encoding for images.
What is the maximum file size Multer can handle?
Multer itself has no hard ceiling, but it is not the right tool for very large uploads because the bytes flow through your Node process. Keep diskStorage under about 25 MB, multer-s3 under about 100 MB, and switch to presigned direct-to-S3 uploads with multipart above that.
Should I use Multer or presigned URLs?
Use Multer when you need to inspect, resize or transform the file server side and the sizes are modest. Use presigned URLs when files are large, when you run serverless, or when you want the transfer cost and bandwidth off your own infrastructure.
Wrapping up
A working React Node.js file upload with Multer takes about twenty lines. A safe one takes a bit more: an allowlist you control, UUID filenames, limits at every layer, magic-byte verification, cleanup of rejected files, and a storage target that survives a second container.
Copy the configuration above, work through the checklist, and you have an upload pipeline that will pass a security review instead of becoming the incident report. Need a second pair of eyes on your upload or storage architecture? Get in touch with our engineering team.
