File Uploads with Multer — Single, Multiple, Fields, and Storage Engines

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)
Note: When sending a file upload request from Angular, use 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.
Tip: Generate unique filenames using a combination of timestamp and random bytes or a UUID library rather than using the original filename. Original filenames can contain dangerous characters (../../../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.
Warning: Always validate the MIME type AND the file extension — not just one. Attackers can rename a PHP script to 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)

🧠 Test Yourself

An Angular component sends a file upload using FormData but the Express/Multer handler always receives an empty req.file. What is the most likely cause?