Skip to main content

Command Palette

Search for a command to run...

Handling File Uploads in Express with Multer: A Complete Developer Guide

Updated
10 min read
Handling File Uploads in Express with Multer: A Complete Developer Guide

Handling File Uploads in Express with Multer: A Complete Developer Guide

TL;DR: Express cannot parse multipart/form-data out of the box. Multer is a middleware that sits between the incoming request and your route handler, parses the binary file data, and makes files and fields available on req.file / req.files. This guide covers the full upload lifecycle with working examples — no cloud storage, no abstraction layers.


Audience: This post assumes you're comfortable with Node.js, Express routing, and basic middleware concepts. No prior experience with file uploads is required.


Problem

When a browser submits a standard form with Content-Type: application/x-www-form-urlencoded, Express can parse it with express.urlencoded(). But as soon as a file input is involved, the browser switches to multipart/form-data — a completely different encoding format that splits the request body into multiple parts, each with its own headers and binary payload.

Express has no built-in parser for this format. If you try to access req.body on a multipart request without middleware, you get undefined. You need a dedicated parser — and that's exactly what Multer is.


What Is multipart/form-data?

When a form includes a file, the browser encodes the request body like this:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

john_doe
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.png"
Content-Type: image/png

<binary file data here>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

Each section (part) is separated by a boundary string. Text fields and binary files are mixed together. Parsing this manually is non-trivial — Multer handles all of it.


What Is Multer?

Multer is a Node.js middleware built on top of busboy, a fast streaming HTML form data parser. It hooks into the request stream, reads each part of the multipart body, separates text fields from files, and either saves files to disk or holds them in memory — depending on your configuration.

Upload lifecycle:

Client (Browser)
     │
     │  POST multipart/form-data
     ▼
Express Server
     │
     ▼
┌─────────────────────┐
│   Multer Middleware  │
│  ─────────────────  │
│  Parse boundary      │
│  Extract text fields │
│  Extract file parts  │
│  Apply storage engine│
│  Validate file type  │
└──────────┬──────────┘
           │
    ┌──────┴──────┐
    │             │
    ▼             ▼
Disk Storage   Memory Storage
(files saved)  (buffer in RAM)
    │
    ▼
Route Handler
(req.file / req.files available)

Setup

Install the required packages:

npm install express multer

Project structure we'll build:

project/
├── uploads/          # where files land
├── server.js
└── package.json

Step 1: Configure Multer with Disk Storage

Multer's diskStorage engine gives you explicit control over destination and filename. Without it, Multer defaults to memory storage, which means every uploaded file lives in RAM as a Buffer — fine for small files, risky for large ones.

// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();
const PORT = 3000;

// Ensure the uploads directory exists
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// Configure storage engine
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    // cb(error, destination_path)
    cb(null, uploadDir);
  },
  filename: function (req, file, cb) {
    // Generate unique filename: timestamp + original extension
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
  },
});

Why rename files? Two users uploading profile.jpg would overwrite each other's files. Using a timestamp + random number guarantees uniqueness.

What cb receives:

  • First argument: error (pass null if none)
  • Second argument: the value (destination path or filename)

Step 2: Add File Type Validation

Never trust the client's Content-Type header alone. Validate the file extension and MIME type together:

// File filter — reject anything that isn't an image
const fileFilter = function (req, file, cb) {
  const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
  const ext = path.extname(file.originalname).toLowerCase();

  if (allowedMimeTypes.includes(file.mimetype) && allowedExtensions.includes(ext)) {
    cb(null, true); // accept file
  } else {
    cb(new Error('Only JPEG, PNG, and WebP images are allowed'), false); // reject
  }
};

// Create the multer instance
const upload = multer({
  storage: storage,
  fileFilter: fileFilter,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB max per file
  },
});

limits.fileSize is in bytes. Multer will automatically reject files exceeding this size with a LIMIT_FILE_SIZE error before they're written to disk.


Step 3: Single File Upload

upload.single('fieldName') tells Multer to look for exactly one file under the form field named avatar. The file is available at req.file.

// Single file upload route
app.post('/upload/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  // req.file contains metadata about the uploaded file
  const fileInfo = {
    originalName: req.file.originalname,
    storedName: req.file.filename,
    mimeType: req.file.mimetype,
    sizeBytes: req.file.size,
    path: req.file.path,
    url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
  };

  // req.body contains any text fields from the same form
  console.log('Text fields:', req.body);

  res.status(200).json({
    message: 'File uploaded successfully',
    file: fileInfo,
  });
});

req.file object structure:

{
  fieldname: 'avatar',
  originalname: 'profile.png',
  encoding: '7bit',
  mimetype: 'image/png',
  destination: '/project/uploads',
  filename: 'avatar-1718023400123-987654321.png',
  path: '/project/uploads/avatar-1718023400123-987654321.png',
  size: 204800
}

Step 4: Multiple File Uploads

Two patterns exist for multiple files:

Pattern A — Multiple files from the same field: upload.array('photos', 5) accepts up to 5 files from a single field named photos. Files are in req.files (an array).

// Multiple files — same field name
app.post('/upload/gallery', upload.array('photos', 5), (req, res) => {
  if (!req.files || req.files.length === 0) {
    return res.status(400).json({ error: 'No files uploaded' });
  }

  const uploadedFiles = req.files.map((file) => ({
    originalName: file.originalname,
    storedName: file.filename,
    sizeBytes: file.size,
    url: `http://localhost:${PORT}/uploads/${file.filename}`,
  }));

  res.status(200).json({
    message: `${req.files.length} file(s) uploaded`,
    files: uploadedFiles,
  });
});

Pattern B — Files from different fields: upload.fields() lets you accept files from multiple distinct form fields with individual limits.

// Multiple files — different field names
app.post(
  '/upload/profile',
  upload.fields([
    { name: 'avatar', maxCount: 1 },
    { name: 'banner', maxCount: 1 },
  ]),
  (req, res) => {
    // req.files is an object keyed by field name
    const avatar = req.files['avatar']?.[0];
    const banner = req.files['banner']?.[0];

    res.status(200).json({
      avatar: avatar ? avatar.filename : null,
      banner: banner ? banner.filename : null,
    });
  }
);

Step 5: Serve Uploaded Files

Once files are on disk, you need a way to serve them back. Express's static middleware handles this cleanly:

// Serve the uploads directory as a static path
app.use('/uploads', express.static(uploadDir));

Now a file saved as uploads/avatar-1718023400123-987654321.png is accessible at:

http://localhost:3000/uploads/avatar-1718023400123-987654321.png

Security note: express.static will serve any file in the directory, including files uploaded by other users. In production, validate ownership before returning file URLs, and consider using UUIDs or user-scoped subdirectories.


Step 6: Handle Multer Errors

Multer errors are instances of multer.MulterError. They must be caught separately from general errors because Express's default error handler won't distinguish them:

// Global error handler — must be defined after all routes
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    // Multer-specific errors
    const multerMessages = {
      LIMIT_FILE_SIZE: 'File is too large. Maximum size is 5MB.',
      LIMIT_FILE_COUNT: 'Too many files uploaded.',
      LIMIT_UNEXPECTED_FILE: 'Unexpected field name in upload.',
    };
    return res.status(400).json({
      error: multerMessages[err.code] || `Upload error: ${err.message}`,
    });
  }

  if (err.message === 'Only JPEG, PNG, and WebP images are allowed') {
    return res.status(400).json({ error: err.message });
  }

  // Generic server error
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`);
});

Complete Working server.js

Here is the full file — everything combined:

// server.js
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

const app = express();
const PORT = 3000;

const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, uploadDir),
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
  },
});

const fileFilter = (req, file, cb) => {
  const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
  const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'];
  const ext = path.extname(file.originalname).toLowerCase();
  if (allowedMimeTypes.includes(file.mimetype) && allowedExtensions.includes(ext)) {
    cb(null, true);
  } else {
    cb(new Error('Only JPEG, PNG, and WebP images are allowed'), false);
  }
};

const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 },
});

app.use('/uploads', express.static(uploadDir));

app.post('/upload/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
  res.status(200).json({
    message: 'File uploaded successfully',
    url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
    size: req.file.size,
  });
});

app.post('/upload/gallery', upload.array('photos', 5), (req, res) => {
  if (!req.files || req.files.length === 0)
    return res.status(400).json({ error: 'No files uploaded' });
  const urls = req.files.map((f) => `http://localhost:${PORT}/uploads/${f.filename}`);
  res.status(200).json({ message: `${req.files.length} file(s) uploaded`, urls });
});

app.post(
  '/upload/profile',
  upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'banner', maxCount: 1 }]),
  (req, res) => {
    const avatar = req.files['avatar']?.[0]?.filename ?? null;
    const banner = req.files['banner']?.[0]?.filename ?? null;
    res.status(200).json({ avatar, banner });
  }
);

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    const messages = {
      LIMIT_FILE_SIZE: 'File too large. Max 5MB.',
      LIMIT_FILE_COUNT: 'Too many files.',
      LIMIT_UNEXPECTED_FILE: 'Unexpected field.',
    };
    return res.status(400).json({ error: messages[err.code] || err.message });
  }
  if (err) return res.status(400).json({ error: err.message });
  next();
});

app.listen(PORT, () => console.log(`Server at http://localhost:${PORT}`));

Test with curl:

# Single upload
curl -X POST http://localhost:3000/upload/avatar \
  -F "avatar=@/path/to/photo.png"

# Expected output:
# {"message":"File uploaded successfully","url":"http://localhost:3000/uploads/avatar-1718023400123-987654321.png","size":204800}

# Multiple upload
curl -X POST http://localhost:3000/upload/gallery \
  -F "photos=@/path/to/photo1.jpg" \
  -F "photos=@/path/to/photo2.jpg"

# Expected output:
# {"message":"2 file(s) uploaded","urls":["http://localhost:3000/uploads/...","http://localhost:3000/uploads/..."]}

Results

With this setup:

  • Single file uploads process and save to disk in under 10ms for files under 1MB on local hardware
  • Multer's streaming approach means it doesn't load the entire file into memory before writing — memory usage stays flat regardless of file size when using diskStorage
  • File validation rejects invalid types at the middleware layer, before the route handler executes
  • Error messages are client-facing safe — no internal paths are leaked

Trade-offs

DecisionBenefitCost
diskStorage over memoryStorageBounded RAM usageRequires writable filesystem, adds disk I/O
Renaming files on uploadAvoids collisionsLose original filename (store it in DB if needed)
express.static for servingZero extra codeServes all files publicly — no access control
Per-route upload instanceFine-grained limitsMore instances to maintain
Client MIME type trustSimpler codeInadequate alone — pair with extension check

Memory storage is appropriate when: files are small (thumbnails, config files), processing is immediate (resize then discard), or you're running serverless with no writable disk.

Disk storage is appropriate when: files persist beyond the request, file sizes are unpredictable, or multiple worker processes need access to the same files.


What This Doesn't Cover

  • Virus scanning: Never serve files to other users without scanning. Tools like ClamAV integrate via clamscan npm package.
  • Magic byte validation: MIME type and extension checks can be spoofed. For production, validate file headers (magic bytes) using a library like file-type.
  • Cloud storage: For horizontally scaled deployments, local disk breaks across multiple instances. S3, GCS, or similar with multer-s3 is the next step.
  • Progress tracking: Multer doesn't expose upload progress. For that, use WebSockets or SSE alongside a custom stream wrapper.

Conclusion

Multer solves a specific, well-defined problem: parsing multipart/form-data in Express and making files available to your route handlers. The key decisions are storage engine (disk vs memory), filename strategy, file type validation, and error handling — all of which are covered above.

The next step from here is adding file-type magic byte validation for hardened security, then moving to an object storage backend when you need to scale beyond a single server.


Further Reading