Skip to main content

📤 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-data and 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.

graph LR A["Browser form
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 files to a folder; memory storage keeps a buffer in RAM diskStorage writes to uploads/ req.file.path ✔ good for: keeping files memoryStorage holds a Buffer in RAM req.file.buffer ✔ good for: process & forward
Pick disk storage to persist files locally; memory storage when you'll immediately process or forward the bytes.

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!');
});
MethodAcceptsRead from
upload.single('x')One file, field xreq.file
upload.array('x', n)Up to n files, field xreq.files (array)
upload.fields([...])Named fields, each with a maxreq.files (object)
upload.none()Text fields only, no filesreq.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

CodeMeaning
LIMIT_FILE_SIZEA file exceeded limits.fileSize
LIMIT_FILE_COUNTMore files than limits.files
LIMIT_UNEXPECTED_FILEA file arrived on an unexpected field name
LIMIT_PART_COUNTToo 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.

graph TD A["Upload security"] --> B["Cap file size"] A --> C["Whitelist types"] A --> D["Verify real content"] A --> E["Randomize filenames"] A --> F["Store outside web root"] A --> G["Require auth"]

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.fileSize to 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.fileSize to prevent memory exhaustion and DoS
  • Whitelist allowed MIME types in a fileFilter, and reject the rest
  • Generate random filenames — never trust originalname for 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.mimetype as 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 in req.body
  • Enforce limits and a fileFilter on 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

🚀 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.