File uploads are a common requirement in real-world applications — user avatars, task attachments, document imports, image galleries. Multer is the standard Express middleware for handling multipart/form-data requests, which is the encoding format used by HTML forms and the Angular FormData API when uploading files. It processes the incoming binary stream, saves files to disk or memory, and makes them available on req.file (single) or req.files (multiple). This lesson covers every Multer configuration — storage engines, file filtering, size limits, multiple file fields, and serving uploaded files securely.
Multer Configuration Options
| Option | Type | Purpose |
|---|---|---|
storage |
StorageEngine | Where and how to save files — DiskStorage or MemoryStorage |
fileFilter |
Function | Accept or reject files based on type, name, or other criteria |
limits.fileSize |
Number (bytes) | Maximum file size — reject larger files |
limits.files |
Number | Maximum number of files per request |
limits.fields |
Number | Maximum number of non-file fields |
limits.fieldSize |
Number (bytes) | Maximum size of each non-file field value |
dest |
String | Shorthand for simple disk storage — files get random names |
DiskStorage Options
| Option | Function Signature | Returns |
|---|---|---|
destination |
(req, file, cb) |
Directory path string via cb(null, '/uploads') |
filename |
(req, file, cb) |
Filename string via cb(null, 'name.ext') |
req.file and req.files Properties
| Property | Type | Description |
|---|---|---|
fieldname |
string | Name of the form field |
originalname |
string | Original file name from the client |
encoding |
string | File encoding type |
mimetype |
string | MIME type — e.g. image/jpeg, application/pdf |
size |
number | File size in bytes |
destination |
string | Directory where file was saved (DiskStorage) |
filename |
string | Name of the saved file (DiskStorage) |
path |
string | Full path to the saved file (DiskStorage) |
buffer |
Buffer | File contents as a Buffer (MemoryStorage only) |
FormData and do NOT manually set the Content-Type header. The browser must set it automatically to multipart/form-data; boundary=---xyz — the boundary string is generated by the browser and must match the encoding in the body. Setting Content-Type manually removes the boundary, causing Multer to fail to parse the request entirely.../../../etc/passwd path traversal) or collide with existing files. A pattern like Date.now() + '-' + Math.round(Math.random() * 1e9) + ext is simple and effective. Never trust req.file.originalname for the saved filename.image.jpg to pass an extension check, or change the MIME type header to image/jpeg to pass a MIME check. For critical security, use a library like file-type that reads the actual file magic bytes (the first few bytes of the file) to determine the true type, regardless of what the client claims.Basic Example
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
// ── Ensure upload directories exist ──────────────────────────────────────
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
fs.mkdirSync(path.join(UPLOAD_DIR, 'avatars'), { recursive: true });
fs.mkdirSync(path.join(UPLOAD_DIR, 'attachments'), { recursive: true });
// ── MIME type whitelists ──────────────────────────────────────────────────
const IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const DOC_MIME_TYPES = new Set([
'image/jpeg', 'image/png', 'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
]);
// ── File filter factory ───────────────────────────────────────────────────
const createFileFilter = (allowedTypes) => (req, file, cb) => {
if (allowedTypes.has(file.mimetype)) {
cb(null, true); // accept
} else {
cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname), false);
}
};
// ── DiskStorage for avatars ───────────────────────────────────────────────
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(UPLOAD_DIR, 'avatars'));
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `${req.user.id}-${Date.now()}${ext}`;
cb(null, uniqueName);
},
});
// ── DiskStorage for task attachments ─────────────────────────────────────
const attachmentStorage = multer.diskStorage({
destination: (req, file, cb) => {
const dir = path.join(UPLOAD_DIR, 'attachments', req.user.id);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const random = crypto.randomBytes(8).toString('hex');
cb(null, `${Date.now()}-${random}${ext}`);
},
});
// ── MemoryStorage for processing before saving elsewhere (e.g. S3) ────────
const memoryStorage = multer.memoryStorage();
// ── Multer instances ──────────────────────────────────────────────────────
const uploadAvatar = multer({
storage: avatarStorage,
fileFilter: createFileFilter(IMAGE_MIME_TYPES),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
});
const uploadAttachment = multer({
storage: attachmentStorage,
fileFilter: createFileFilter(DOC_MIME_TYPES),
limits: { fileSize: 10 * 1024 * 1024, files: 5 }, // 10 MB, max 5 files
});
// ── Single file upload ────────────────────────────────────────────────────
router.post('/users/me/avatar',
authenticate,
uploadAvatar.single('avatar'), // 'avatar' = FormData field name
async (req, res) => {
if (!req.file) return res.status(400).json({ message: 'No file uploaded' });
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
await User.findByIdAndUpdate(req.user.id, { avatar: avatarUrl });
res.json({ success: true, data: { avatarUrl } });
}
);
// ── Multiple files (same field) ───────────────────────────────────────────
router.post('/tasks/:id/attachments',
authenticate,
uploadAttachment.array('files', 5), // up to 5 files in 'files' field
async (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ message: 'No files uploaded' });
}
const attachments = req.files.map(f => ({
originalName: f.originalname,
filename: f.filename,
size: f.size,
mimeType: f.mimetype,
url: `/uploads/attachments/${req.user.id}/${f.filename}`,
}));
await Task.findByIdAndUpdate(
req.params.id,
{ $push: { attachments: { $each: attachments } } }
);
res.status(201).json({ success: true, data: attachments });
}
);
// ── Multiple fields (different field names) ───────────────────────────────
const uploadDocuments = multer({ storage: attachmentStorage, limits: { fileSize: 20 * 1024 * 1024 } });
router.post('/submissions',
authenticate,
uploadDocuments.fields([
{ name: 'document', maxCount: 1 },
{ name: 'images', maxCount: 3 },
{ name: 'cover', maxCount: 1 },
]),
async (req, res) => {
const { document, images, cover } = req.files;
res.json({
success: true,
data: {
document: document?.[0]?.filename,
images: images?.map(f => f.filename),
cover: cover?.[0]?.filename,
}
});
}
);
Handling Multer Errors
// middleware/multerErrorHandler.js
const multer = require('multer');
const multerErrorHandler = (err, req, res, next) => {
if (err instanceof multer.MulterError) {
const messages = {
LIMIT_FILE_SIZE: 'File is too large',
LIMIT_FILE_COUNT: 'Too many files uploaded',
LIMIT_UNEXPECTED_FILE:'File type not allowed',
LIMIT_FIELD_COUNT: 'Too many fields in the form',
};
return res.status(400).json({
success: false,
message: messages[err.code] || 'File upload error',
code: err.code,
});
}
next(err);
};
module.exports = multerErrorHandler;
// app.js — register AFTER routes, before the global error handler
app.use('/api/v1', require('./routes'));
app.use(multerErrorHandler); // catches Multer errors specifically
app.use(errorHandler); // catches all other errors
How It Works
Step 1 — Multer Parses the Multipart Body Stream
A multipart/form-data request body is a binary stream divided into parts by a boundary string. Each part contains headers (field name, filename, content type) followed by the content. Multer reads this stream using the busboy library, separates file parts from text field parts, and processes each based on the configured storage engine.
Step 2 — DiskStorage Saves Files to the Filesystem
For each incoming file, DiskStorage calls your destination function to determine the directory, then your filename function to determine the name. It creates a write stream to the resulting path and pipes the file content from the request stream into it. When the write completes, Multer adds the file’s metadata to req.file or req.files.
Step 3 — fileFilter Runs Before the File Is Saved
Before writing to disk, Multer calls your fileFilter function with the file’s metadata (including MIME type). Calling cb(null, true) accepts the file. Calling cb(null, false) silently rejects it (no error — the file is simply ignored). Calling cb(new Error()) triggers an error. Combine MIME type checking here with extension validation to prevent uploading malicious files.
Step 4 — limits Enforces Size and Count Constraints
The limits object tells Multer to abort the upload and throw a MulterError if any constraint is exceeded. fileSize is checked against the Content-Length header and actual bytes written, so clients cannot bypass it by lying about the size. Multer errors have a code property like LIMIT_FILE_SIZE that you can use in your error handler to send a specific message.
Step 5 — Text Fields Are Also Available via req.body
A multipart/form-data request can contain both files and regular text fields. Multer parses the text fields and makes them available on req.body exactly like express.json() does for JSON requests. This lets you send file metadata (alt text, description, category) alongside the file in a single request — no need for a separate API call to save the metadata.
Real-World Example: Avatar Upload with Cleanup
// controllers/user.controller.js
const path = require('path');
const fs = require('fs/promises');
const User = require('../models/user.model');
const asyncHandler = require('../utils/asyncHandler');
const UPLOAD_BASE = path.join(__dirname, '..', 'uploads', 'avatars');
exports.uploadAvatar = asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ success: false, message: 'Please upload an image file' });
}
const user = await User.findById(req.user.id);
// Delete old avatar file if it exists and is not the default
if (user.avatar && !user.avatar.includes('default-avatar')) {
const oldFile = path.join(UPLOAD_BASE, path.basename(user.avatar));
try {
await fs.unlink(oldFile);
} catch {
// Old file missing is not a critical error — continue
}
}
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
user.avatar = avatarUrl;
await user.save();
res.json({
success: true,
data: { avatarUrl, message: 'Avatar updated successfully' },
});
});
exports.deleteAvatar = asyncHandler(async (req, res) => {
const user = await User.findById(req.user.id);
if (user.avatar && !user.avatar.includes('default-avatar')) {
const filePath = path.join(UPLOAD_BASE, path.basename(user.avatar));
await fs.unlink(filePath).catch(() => {});
}
user.avatar = '/uploads/avatars/default-avatar.png';
await user.save();
res.json({ success: true, data: { avatarUrl: user.avatar } });
});
Common Mistakes
Mistake 1 — Using the original filename directly for disk storage
❌ Wrong — path traversal attack and name collision:
filename: (req, file, cb) => {
cb(null, file.originalname); // '../../etc/passwd' or 'script.php' — dangerous!
}
✅ Correct — generate a safe unique filename:
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const safe = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
cb(null, safe);
}
Mistake 2 — Setting Content-Type manually in Angular when uploading
❌ Wrong — manual Content-Type removes the boundary string:
// Angular — DO NOT set Content-Type for FormData requests
this.http.post('/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' } // BREAKS upload — no boundary!
});
✅ Correct — let the browser set Content-Type automatically:
this.http.post('/api/upload', formData); // browser sets correct Content-Type + boundary
Mistake 3 — Not handling MulterError separately from general errors
❌ Wrong — Multer size limit error returns generic 500:
app.use((err, req, res, next) => {
res.status(500).json({ message: err.message }); // 'File too large' shown as 500
});
✅ Correct — detect MulterError and return 400:
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ success: false, message: 'File upload error: ' + err.message });
}
next(err);
});
Quick Reference
| Task | Code |
|---|---|
| Single file | upload.single('fieldName') → req.file |
| Multiple files same field | upload.array('fieldName', 5) → req.files |
| Multiple different fields | upload.fields([{name:'a',maxCount:1},{name:'b',maxCount:3}]) → req.files.a |
| Memory storage | multer({ storage: multer.memoryStorage() }) → req.file.buffer |
| Limit file size | multer({ limits: { fileSize: 5 * 1024 * 1024 } }) |
| Filter by MIME | fileFilter: (req, file, cb) => cb(null, allowedTypes.has(file.mimetype)) |
| File path on disk | req.file.path or path.join(dest, req.file.filename) |
| Delete file | await fs.promises.unlink(req.file.path) |