Express’s express.json() middleware can’t parse file uploads. Files come in as multipart/form-data, a format designed for mixed text + binary data. Multer is the standard tool for parsing it.
In simple language: a multipart request is a bunch of “parts” glued together with a boundary string, each part has its own headers. Multer carves it up so we get clean req.file and req.body objects.
The upload pipeline
Content-Type: multipart/form-data; boundary=----abc
[avatar binary][caption: "Vacation"]
↓
multer.single("avatar") parses the stream
↓
req.file = { fieldname, originalname, mimetype, size, path/buffer }
req.body = { caption: "Vacation" }
Basic setup
npm install multer
import multer from "multer";
const upload = multer({ dest: "uploads/" });
app.post("/profile", upload.single("avatar"), (req, res) => {
console.log(req.file);
// {
// fieldname: 'avatar',
// originalname: 'cat.jpg',
// mimetype: 'image/jpeg',
// destination: 'uploads/',
// filename: '8d7f...',
// path: 'uploads/8d7f...',
// size: 50234
// }
console.log(req.body); // other form fields
res.json({ uploaded: req.file.filename });
});
upload.single("avatar") says “expect one file in a form field called avatar”. For multiple files: upload.array("photos", 5) or upload.fields([{ name: "avatar", maxCount: 1 }, { name: "docs", maxCount: 3 }]).
Disk vs memory storage
This is the design decision that matters.
// Disk: writes to filesystem, req.file.path points to it
const diskStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, "uploads/"),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${crypto.randomUUID()}${ext}`);
},
});
// Memory: keeps file in RAM as a Buffer, req.file.buffer
const memoryStorage = multer.memoryStorage();
| Storage | When to use | Watch out for |
|---|---|---|
| Disk | Large files, eventual persistence | Disk fills up, cleanup is on you |
| Memory | Forwarding to S3, image processing, small files | OOM if files are big or concurrent uploads spike |
The classic pattern for S3 uploads is memory storage → pipe the buffer straight to S3, never touch local disk.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({ region: "us-east-1" });
const upload = multer({ storage: multer.memoryStorage() });
app.post("/upload", upload.single("file"), async (req, res) => {
await s3.send(new PutObjectCommand({
Bucket: "my-app-uploads",
Key: `${crypto.randomUUID()}-${req.file.originalname}`,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
res.json({ ok: true });
});
Size limits and file filters
Always set limits. Always. Without them, someone uploads a 50GB file and your server cries.
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 1,
},
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.mimetype)) {
return cb(new Error("Only JPEG/PNG/WebP allowed"));
}
cb(null, true);
},
});
When the limit trips, Multer throws a MulterError. Handle it with an error middleware:
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(413).json({ error: "File too large (max 5MB)" });
}
return res.status(400).json({ error: err.message });
}
next(err);
});
Security gotchas
The MIME type from the client is not trustworthy. The Content-Type header is set by the browser based on the file extension — an attacker can rename evil.exe to cat.jpg and the mimetype will say image/jpeg.
For real validation, sniff the actual bytes:
npm install file-type
import { fileTypeFromBuffer } from "file-type";
app.post("/upload", upload.single("file"), async (req, res) => {
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !["jpg", "png", "webp"].includes(detected.ext)) {
return res.status(400).json({ error: "Not a valid image" });
}
// safe to proceed
});
Other rules:
- Never use
originalnameas a path — it can contain../traversal or weird chars. Generate UUIDs. - Strip metadata for privacy (EXIF GPS data in photos). Use
sharpto re-encode. - Store outside the web root if using disk storage, or files become directly accessible URLs.
- Scan for malware if you accept arbitrary file types — ClamAV or a service like VirusTotal.
Quick reference
upload.single("avatar") // one file
upload.array("photos", 5) // up to 5 files, same field
upload.fields([{ name: "avatar" }, { name: "docs" }]) // multiple fields
upload.none() // no files, just form fields
upload.any() // anything — usually avoid