File Uploads with Multer
Browsers send files using multipart/form-data, an encoding Express does not parse out of the box — express.json() and express.urlencoded() both ignore file parts. Multer is the standard middleware that decodes multipart bodies, writes the uploaded files where you tell it, and exposes them on req.file or req.files. Getting storage, file filtering, and size limits right is what keeps an upload endpoint from becoming a disk-filling, malware-accepting liability.
Installing and configuring Multer
Install Multer alongside Express. You attach it per-route (not globally) because only a handful of endpoints accept uploads, and Multer must know in advance which form field names to expect.
npm install multer
Multer needs a storage engine that decides where and how each file is persisted. The two built-in engines are disk storage and memory storage.
const express = require("express");
const multer = require("multer");
const path = require("path");
const crypto = require("crypto");
const app = express();
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, "uploads/"),
filename: (req, file, cb) => {
const unique = crypto.randomBytes(8).toString("hex");
cb(null, `${unique}${path.extname(file.originalname)}`);
}
});
const upload = multer({ storage });
Warning: Never reuse
file.originalnameas the saved filename. A client can send../../etc/passwdor collide with existing files. Always generate a random name and re-attach a known-safe extension, as above.
Disk vs memory storage
| Engine | Where files go | Use when |
|---|---|---|
multer.diskStorage | Written to a folder on disk; req.file.path points to it. | Large files, local serving, or piping to long-term storage. |
multer.memoryStorage | Held in RAM as a Buffer on req.file.buffer. | Small files you immediately forward to S3, resize with Sharp, etc. |
Memory storage avoids touching the filesystem but loads the whole file into the process heap, so always pair it with a strict size limit.
Single file uploads
upload.single(fieldName) accepts exactly one file from the named form field and attaches it to req.file. Any non-file fields in the same form land on req.body.
app.post("/avatar", upload.single("avatar"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
res.json({
storedAs: req.file.filename,
size: req.file.size,
type: req.file.mimetype,
caption: req.body.caption // a normal text field from the same form
});
});
Output:
{
"storedAs": "9f3c1a7be20d4e88.png",
"size": 48213,
"type": "image/png",
"caption": "my profile picture"
}
The req.file object also includes originalname, path (disk storage), destination, and buffer (memory storage). It is undefined when no file was sent, so always guard for it.
Multiple file uploads
For several files under one field, use upload.array(fieldName, maxCount); the files arrive as an array on req.files. For files spread across different named fields, use upload.fields([...]), which groups them into an object keyed by field name.
// Up to 5 files, all from a <input name="photos" multiple>
app.post("/gallery", upload.array("photos", 5), (req, res) => {
const names = req.files.map((f) => f.filename);
res.json({ count: req.files.length, files: names });
});
// Distinct fields with their own limits
const mixed = upload.fields([
{ name: "cover", maxCount: 1 },
{ name: "attachments", maxCount: 3 }
]);
app.post("/post", mixed, (req, res) => {
res.json({
cover: req.files.cover?.[0]?.filename,
attachments: (req.files.attachments || []).map((f) => f.filename)
});
});
Use upload.none() when a form is multipart but carries no files (Multer then only populates req.body), and upload.any() only as a last resort — it accepts every field and is easy to abuse.
File filtering and size limits
The fileFilter function runs before each file is stored and decides whether to accept it. Call cb(null, true) to accept, cb(null, false) to silently skip, or cb(new Error(...)) to reject the whole request. Limits are enforced by the limits option and are your primary defense against denial-of-service uploads.
const upload = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024, // 2 MB per file
files: 5 // max number of files
},
fileFilter: (req, file, cb) => {
const allowed = ["image/png", "image/jpeg", "image/webp"];
if (!allowed.includes(file.mimetype)) {
return cb(new Error("Only PNG, JPEG, and WebP images are allowed"));
}
cb(null, true);
}
});
Useful limits keys
| Key | Meaning |
|---|---|
fileSize | Maximum size of each file, in bytes. |
files | Maximum number of file fields total. |
fields | Maximum number of non-file fields. |
parts | Maximum number of parts (files + fields combined). |
Handling Multer errors
When a limit is exceeded or the filter rejects a file, Multer calls next(err). A MulterError (e.g. LIMIT_FILE_SIZE) signals a built-in limit; a plain Error is the one your fileFilter threw. Catch both in an error-handling middleware so clients get a clean 4xx instead of a stack trace.
const { MulterError } = require("multer");
app.use((err, req, res, next) => {
if (err instanceof MulterError) {
return res.status(413).json({ error: err.code }); // e.g. LIMIT_FILE_SIZE
}
if (err) {
return res.status(400).json({ error: err.message });
}
next();
});
Output (file over 2 MB):
{ "error": "LIMIT_FILE_SIZE" }
Tip: MIME-type checks are easily spoofed by a malicious client. For untrusted uploads, verify the real file signature (magic bytes) with a library like
file-typeafter Multer stores the file, and treat the user-suppliedmimetypeas a hint only.
Express 5 notes
Multer works identically on Express 4 and 5 — its single/array/fields middleware signatures are unchanged. The key difference is error propagation: in Express 5, errors thrown from async route handlers are forwarded to your error middleware automatically, so the Multer error handler above catches storage and post-processing failures more reliably without manual try/catch wrapping.
Best Practices
- Attach Multer only to upload routes, never with a global
app.use, so other endpoints stay free of multipart parsing. - Always set
limits.fileSize; an unbounded upload endpoint is a trivial denial-of-service target. - Generate random filenames and re-attach a safe extension — never trust
originalnameon disk. - Validate file content by magic bytes, not just the client-supplied
mimetype. - Prefer
memoryStoragewith tight limits when forwarding straight to cloud storage; usediskStoragefor large or locally served files. - Add a dedicated error handler that distinguishes
MulterErrorfrom yourfileFiltererrors and returns a meaningful status code. - Store uploads outside your web root (or behind access checks) so users cannot guess and fetch each other’s files.