📤 File Uploads with Multer
Avatars, product photos, PDF invoices, CSV imports — sooner or later your API has to accept a file. Files arrive in a different envelope than JSON (multipart/form-data), and the built-in parsers ignore it. Multer is the middleware that opens that envelope and hands you the file.
Week 7 · Day 4 (Thursday: Working with Data) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why file uploads use
multipart/form-dataand need dedicated middleware - Install and configure Multer with disk and memory storage strategies
- Accept single files, multiple files, and multiple named fields
- Enforce size limits and reject unwanted file types with a
fileFilter - Handle Multer errors gracefully and return clear responses
- Apply core security measures: safe filenames, whitelists, and content checks
Estimated Time: 60 minutes
Practice: Build an image-upload endpoint with type/size limits and error handling.
In This Lesson
Why File Uploads Are Different
A JSON body is just text — Express reads it, calls JSON.parse(), and you're done. A file is binary: it can be megabytes of image or video data mixed together with ordinary text fields in the same request. To carry that safely, browsers use a special encoding called multipart/form-data, which chops the request into labeled "parts," one per field or file.
Think of the difference like mailing a letter versus shipping a package. A letter (JSON) slips through the normal slot. A package (a file) needs wrapping, a label, and a different handling channel because of its size and contents. express.json() handles letters; it has no idea what to do with packages. That's Multer's job.
multipart/form-data"] --> B["Express route"] B --> C["Multer middleware"] C --> D["Disk / Memory storage"] D --> E["req.file / req.files"] E --> F["Your handler"]
Common places you'll reach for this: profile avatars, product images for a store, document management (PDFs, spreadsheets), media sharing, and data imports (CSV/JSON). The HTML side always looks the same — note the enctype:
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
⚠️ Forget enctype and the file vanishes
Without enctype="multipart/form-data", the browser sends only the file's name as plain text, not its contents. This is the number-one "my upload is empty" bug. The enctype is mandatory for any form with a file input.
Installing & Setting Up Multer
Multer isn't built into Express — it's a separate package maintained by the Express team. Install it, then wire it into a route:
npm install multer
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// Configure where and how uploaded files are saved
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // folder must already exist (or create it — see below)
},
filename: (req, file, cb) => {
// Build a unique name so two uploads never collide
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, file.fieldname + '-' + unique + path.extname(file.originalname));
}
});
const upload = multer({ storage });
// upload.single('file') is the middleware; 'file' is the form field name
app.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.json({
message: 'File uploaded successfully',
file: {
filename: req.file.filename,
originalname: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size
}
});
});
app.listen(3000, () => console.log('Server on http://localhost:3000'));
After Multer runs, it adds two things to the request:
req.file— the uploaded file's metadata (for a single-file upload)req.body— any text fields that were in the same form
📖 The cb (callback) pattern
Multer's configuration functions receive a Node-style callback cb(error, value). Call cb(null, value) to succeed with a value, or cb(new Error('...')) to reject. It looks unusual at first, but it's the same error-first callback convention Node uses throughout.
💡 Create the upload folder if it's missing
const fs = require('fs');
const uploadDir = 'uploads/';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
Disk storage does not create the destination for you — if the folder doesn't exist, the upload fails.
Storage: Disk vs Memory
Multer's storage engine decides where the incoming bytes go. The two built-in choices suit different jobs.
Disk storage
Writes each upload straight to a folder and gives you req.file.path. Best when you're keeping files on the server's filesystem.
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname)}`);
}
});
Memory storage
Keeps the file in RAM as a Buffer on req.file.buffer. Ideal when you'll resize an image, stream it to cloud storage, or scan it — anything where writing to disk first would be wasteful.
const upload = multer({ storage: multer.memoryStorage() });
app.post('/process-image', upload.single('image'), (req, res) => {
const buffer = req.file.buffer; // raw bytes, ready to process
// e.g. sharp(buffer).resize(300, 300)... then forward to S3
res.json({ message: 'Received', bytes: buffer.length });
});
⚠️ Memory storage and large files don't mix
A memory-stored file lives entirely in RAM. Uploading a 500MB video to memory storage can crash your process. Use memory storage only for small files, and always pair it with a limits.fileSize cap (next section).
Single, Multiple & Fields
Multer gives you three method shapes depending on how many files and fields you expect. The method name determines whether you read req.file or req.files.
One file — upload.single(field)
app.post('/profile', upload.single('avatar'), (req, res) => {
console.log(req.file);
/* {
fieldname: 'avatar',
originalname: 'profile.jpg',
mimetype: 'image/jpeg',
filename: '1620312345678-293847501.jpg',
path: 'uploads/1620312345678-293847501.jpg',
size: 58243
} */
console.log(req.body); // any text fields sent alongside
res.send('Profile updated!');
});
Many files, one field — upload.array(field, max)
// Up to 5 files, all from a field named 'photos'
app.post('/gallery', upload.array('photos', 5), (req, res) => {
console.log(`Received ${req.files.length} files`); // req.files is an ARRAY
const files = req.files.map((f) => ({
name: f.originalname,
type: f.mimetype,
size: f.size
}));
res.json({ message: 'Files uploaded', files });
});
Different files, different fields — upload.fields([...])
const uploadMixed = upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'gallery', maxCount: 5 }
]);
app.post('/profile-complete', uploadMixed, (req, res) => {
// req.files is an OBJECT keyed by field name
console.log(req.files.avatar[0].originalname);
console.log(`${req.files.gallery.length} gallery images`);
res.send('Profile updated with gallery!');
});
| Method | Accepts | Read from |
|---|---|---|
upload.single('x') | One file, field x | req.file |
upload.array('x', n) | Up to n files, field x | req.files (array) |
upload.fields([...]) | Named fields, each with a max | req.files (object) |
upload.none() | Text fields only, no files | req.body |
Limits & File Filters
Never accept an upload unconditionally. Two options turn a naive endpoint into a defensible one: limits caps size and count, and fileFilter decides which files are allowed in.
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB per file, in bytes
files: 5 // at most 5 files per request
},
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowed.includes(file.mimetype)) {
// Reject: pass an Error. Multer stops and surfaces it.
return cb(new Error('Only JPEG, PNG, and GIF images are allowed'));
}
cb(null, true); // Accept
}
});
💡 Reading byte sizes at a glance
5 * 1024 * 1024 is 5 megabytes. The pattern is MB * 1024 * 1024. Writing the math out (rather than a magic 5242880) makes the intent obvious to the next reader.
⚠️ mimetype is a claim, not proof
file.mimetype comes from the client and can be spoofed — an attacker can label an .exe as image/png. A mimetype whitelist stops honest mistakes and casual abuse, but for real security you must also inspect the file's actual bytes (magic numbers). We cover that in the security section.
Handling Upload Errors
When a file is too big or a filter rejects it, Multer throws. If you don't catch it, the client gets an ugly stack trace. There are two clean ways to handle these.
Option A: an Express error-handling middleware
Multer size/count problems throw a multer.MulterError with a code; filter rejections throw your own Error. Branch on the type:
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ message: 'Uploaded', file: req.file.filename });
});
// Error-handling middleware — four params, placed AFTER the routes
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds the 5MB limit' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files' });
}
return res.status(400).json({ error: err.message, code: err.code });
}
if (err) {
// A non-Multer error, e.g. thrown by our fileFilter
return res.status(400).json({ error: err.message });
}
next();
});
Option B: wrap the middleware and catch inline
Sometimes you want the error handled right at the route. Call the Multer middleware yourself and pass a callback:
app.post('/upload', (req, res) => {
upload.single('file')(req, res, (err) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: err.message, code: err.code });
}
if (err) {
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return res.status(400).json({ error: 'No file provided' });
}
res.json({ message: 'Uploaded', file: req.file.filename });
});
});
📖 Common MulterError codes
| Code | Meaning |
|---|---|
LIMIT_FILE_SIZE | A file exceeded limits.fileSize |
LIMIT_FILE_COUNT | More files than limits.files |
LIMIT_UNEXPECTED_FILE | A file arrived on an unexpected field name |
LIMIT_PART_COUNT | Too many parts in the multipart form |
Upload Security
A public upload endpoint is one of the riskiest things you can expose. Files can be enormous (DoS), disguised as harmless types, or crafted to escape your upload folder. Layer these defenses.
1. Randomize filenames to stop path traversal
An original filename like ../../etc/passwd could let an attacker write outside your folder. Never trust it — generate your own name:
const crypto = require('crypto');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
// 16 random bytes + the original extension only
const random = crypto.randomBytes(16).toString('hex');
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${random}${ext}`);
}
});
2. Verify the actual file contents
Because mimetype can lie, confirm the bytes match. With memory storage you can inspect the buffer using a library like file-type, which reads the file's magic numbers:
// npm install file-type
const { fileTypeFromBuffer } = require('file-type');
app.post('/secure-upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const detected = await fileTypeFromBuffer(req.file.buffer);
const allowed = ['image/jpeg', 'image/png'];
if (!detected || !allowed.includes(detected.mime)) {
return res.status(400).json({ error: 'File contents are not a valid image' });
}
res.json({ ok: true, realType: detected.mime });
});
✅ The security checklist
- Size limits — always set
limits.fileSizeto prevent DoS - Type whitelist — allow known-good MIME types, deny everything else
- Content verification — check magic numbers, not just the claimed type
- Random filenames — prevent path traversal and collisions
- Store outside the web root — never let uploads be executed as code
- Authenticate & authorize — restrict who can upload and who can read back
💡 For production, prefer cloud storage
Serious apps rarely keep uploads on the app server. Streaming straight to a service like Amazon S3 (via the multer-s3 storage engine) gives you scalability, redundancy, CDN delivery, and built-in access controls — and your file survives even if the server restarts. Local disk is perfect for learning; cloud is the production answer.
Practice & Quiz
🏋️ Exercise 1: A guarded image uploader
Goal: Configure Multer to accept a single image on the field photo, save it to uploads/ with a unique name, reject anything that isn't JPEG or PNG, and cap the size at 2MB. Respond 201 with the stored filename and size, or a clear error.
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// TODO: build storage, limits, fileFilter, the route, and error handling
💡 Hint
Use multer.diskStorage with a filename that combines Date.now() and path.extname(). In fileFilter, allow ['image/jpeg', 'image/png'] and cb(new Error(...)) otherwise. Add an error-handling middleware to turn LIMIT_FILE_SIZE into a 413.
✅ Solution
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png'];
if (!allowed.includes(file.mimetype)) {
return cb(new Error('Only JPEG and PNG images are allowed'));
}
cb(null, true);
}
});
app.post('/upload', upload.single('photo'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file provided' });
res.status(201).json({ filename: req.file.filename, size: req.file.size });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds the 2MB limit' });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
app.listen(3000);
🏋️ Exercise 2: Gallery with metadata
Goal: Accept up to 4 images on a field named images, plus a text field albumName. Return the album name and an array of { name, size } for each uploaded file.
✅ Solution
app.post('/album', upload.array('images', 4), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'At least one image is required' });
}
const files = req.files.map((f) => ({ name: f.originalname, size: f.size }));
res.status(201).json({
album: req.body.albumName ?? 'Untitled', // text field rides along in req.body
count: files.length,
files
});
});
Remember: with upload.array() the files are in req.files (an array), while ordinary text fields like albumName are in req.body.
🎯 Quick Quiz
Question 1: Why can't express.json() handle a file upload?
Question 2: After upload.array('photos', 3), where are the uploaded files?
Question 3: Why is checking only file.mimetype insufficient for security?
Best Practices & Pitfalls
✅ Do
- Always set
limits.fileSizeto prevent memory exhaustion and DoS - Whitelist allowed MIME types in a
fileFilter, and reject the rest - Generate random filenames — never trust
originalnamefor the saved path - Add an error-handling middleware that maps Multer codes to clean responses
- Use memory storage only for small files you'll process immediately
- Store uploads outside the web root, or on cloud storage in production
❌ Don't
- Forget
enctype="multipart/form-data"on the HTML form - Trust
file.mimetypeas proof of the real file type - Save files under their client-supplied name (path-traversal risk)
- Use memory storage for large uploads (it can crash the process)
- Assume the upload folder exists — create it if needed
- Leave the endpoint unauthenticated if uploads should be restricted
⚠️ req.file may be undefined
// ❌ Crashes if the client sent no file
res.json({ name: req.file.filename });
// ✅ Guard first
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ name: req.file.filename });
Summary
🎉 Key Takeaways
- File uploads use multipart/form-data; the built-in parsers ignore it, so you need Multer
- Choose disk storage to keep files or memory storage to process bytes on the fly
single()→req.file;array()/fields()→req.files; text fields ride inreq.body- Enforce
limitsand afileFilteron every upload endpoint - Catch
MulterErrors and return clear, consistent responses - Security means random names, content verification, size caps, and access control — mimetype alone is not enough
📚 Additional Resources
- Express — Multer middleware guide
- Multer — official GitHub repository & API
- MDN — MIME types
- OWASP — Unrestricted File Upload risks
🚀 What's Next?
You've now handled every kind of incoming data — JSON, query strings, form fields, and files. Across all of them, one theme kept recurring: what happens when something goes wrong? The next lesson brings that together into a single, robust strategy: Error handling middleware.
🎉 Nicely done!
From avatars to invoices, you can now accept files safely — a feature almost every real app needs.