Skip to main content

Command Palette

Search for a command to run...

File Uploads in Node.js with Multer

Updated
•7 min read•View as Markdown

So you're building a form that lets users upload a profile photo, a resume, or maybe multiple images at once. You write your Express route, hit submit — and nothing works. No file. Just an empty req.body.

That's because file uploads don't work like regular form fields. And that's exactly what this guide is about.

We'll go from "why does this even need middleware" to "I have a working upload endpoint" — step by step.

1. Why File Uploads Need Middleware

When a regular HTML form submits text data, it sends it as application/x-www-form-urlencoded — a simple key=value string. Express can parse that out of the box.

But when a form includes a file input, the browser switches to a completely different format: multipart/form-data.

Here's what that actually means:

Instead of a flat string, the browser splits the request body into multiple parts, each separated by a boundary string. Every part has its own headers (like Content-Disposition and Content-Type) and a body — which for files is raw binary data.

A simplified look at what the request body looks like over the wire:

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

john_doe
--boundary123
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg

<binary data here>
--boundary123--

Express has no idea how to read this by default. It doesn't parse binary streams. That's why you need middleware — specifically, something that can intercept the request, parse the multipart body, extract the file, and hand it to your route handler in a usable form.

That middleware is Multer.

2. What Multer Is

Multer is an official, well-maintained Node.js middleware for handling multipart/form-data. It's built on top of busboy, a streaming HTML form data parser.

When Multer intercepts a request, here's what it does internally:

Incoming Request (multipart/form-data)
         │
         ▼
   ┌─────────────┐
   │   Multer    │  ← parses the boundary, reads each part
   └──────┬──────┘
          │
    ┌─────┴──────┐
    │            │
    ▼            ▼
Text fields    File buffers
  → req.body   → req.file / req.files
                     │
                     ▼
              Write to disk (or memory)

Install it:

npm install multer

3. Handling a Single File Upload

Let's start with the most common case — a user uploads one file.

Your HTML form:

<form action="/upload" method="POST" enctype="multipart/form-data">
  <input type="file" name="avatar" />
  <button type="submit">Upload</button>
</form>

⚠️ enctype="multipart/form-data" is non-negotiable. Without it, the file never reaches your server as binary data.

Your Express route:

const express = require('express');
const multer  = require('multer');
const path    = require('path');

const app    = express();
const upload = multer({ dest: 'uploads/' }); // files go into /uploads folder

app.post('/upload', upload.single('avatar'), (req, res) => {
  // upload.single('avatar') → matches the input name="avatar"
  
  console.log(req.file);   // the uploaded file's metadata
  console.log(req.body);   // any other text fields in the form

  res.send('File uploaded successfully!');
});

app.listen(3000);

After a successful upload, req.file looks like this:

{
  "fieldname": "avatar",
  "originalname": "photo.jpg",
  "encoding": "7bit",
  "mimetype": "image/jpeg",
  "destination": "uploads/",
  "filename": "3f2a91bc74e1d",
  "path": "uploads/3f2a91bc74e1d",
  "size": 204800
}

Notice that Multer renames the file by default (the filename field above). It strips the extension entirely. We'll fix that in the storage configuration section.

4. Handling Multiple File Uploads

Two variations here: multiple files from one input, or files from multiple different inputs.

Multiple files from one field

<input type="file" name="photos" multiple />
// Accept up to 5 files from the "photos" field
app.post('/upload-many', upload.array('photos', 5), (req, res) => {
  console.log(req.files); // array of file objects
  res.send(`${req.files.length} files uploaded`);
});

req.files is now an array of the same file objects you saw in req.file.

Files from multiple different fields

<input type="file" name="resume" />
<input type="file" name="coverLetter" />
const cpUpload = upload.fields([
  { name: 'resume',      maxCount: 1 },
  { name: 'coverLetter', maxCount: 1 }
]);

app.post('/apply', cpUpload, (req, res) => {
  console.log(req.files['resume']);      // array with 1 file
  console.log(req.files['coverLetter']); // array with 1 file
});

5. Storage Configuration Basics

Using dest: 'uploads/' is quick but limited — you can't control the filename or folder structure. For real control, use multer.diskStorage().

const storage = multer.diskStorage({
  
  // Where to save the file
  destination: function (req, file, cb) {
    cb(null, 'uploads/');
  },

  // What to name the file
  filename: function (req, file, cb) {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname); // e.g. ".jpg"
    cb(null, file.fieldname + '-' + uniqueSuffix + ext);
    // result: avatar-1714823910432-593820193.jpg
  }

});

const upload = multer({ storage });

Why the random suffix? If two users upload profile.jpg at the same time, one will overwrite the other. The suffix makes filenames collision-safe.

Adding File Type Validation

You almost always want to restrict what file types are accepted:

const upload = multer({
  storage,
  limits: {
    fileSize: 2 * 1024 * 1024 // 2MB max
  },
  fileFilter: function (req, file, cb) {
    const allowedTypes = /jpeg|jpg|png|webp/;
    const extValid  = allowedTypes.test(path.extname(file.originalname).toLowerCase());
    const mimeValid = allowedTypes.test(file.mimetype);

    if (extValid && mimeValid) {
      cb(null, true);  // accept
    } else {
      cb(new Error('Only image files are allowed'));
    }
  }
});

Checking both extension and mimetype matters. A user could rename malware.exe to malware.jpg — checking only the extension would let it through.

6. Serving Uploaded Files

Saving files is only half the story. You need to expose them over HTTP so the client can actually view or download them.

// Serve everything in the /uploads folder as static files
app.use('/uploads', express.static('uploads'));

Now a file saved at uploads/avatar-1714823910432.jpg is accessible at:

http://localhost:3000/uploads/avatar-1714823910432.jpg

To send back the URL after an upload:

app.post('/upload', upload.single('avatar'), (req, res) => {
  const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;
  res.json({ url: fileUrl });
});

Handling Errors Gracefully

Multer throws specific errors you should catch:

app.post('/upload', (req, res) => {
  upload.single('avatar')(req, res, function (err) {
    if (err instanceof multer.MulterError) {
      // e.g. file too large
      return res.status(400).json({ error: err.message });
    } else if (err) {
      // custom fileFilter error
      return res.status(400).json({ error: err.message });
    }
    // success
    res.json({ file: req.file });
  });
});

Putting It All Together

Here's a complete, minimal working example:

const express = require('express');
const multer  = require('multer');
const path    = require('path');

const app = express();

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, 'uploads/'),
  filename:    (req, file, cb) => {
    const unique = Date.now() + path.extname(file.originalname);
    cb(null, unique);
  }
});

const upload = multer({
  storage,
  limits: { fileSize: 2 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    /image\/(jpeg|png|webp)/.test(file.mimetype)
      ? cb(null, true)
      : cb(new Error('Images only'));
  }
});

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

app.post('/upload', (req, res) => {
  upload.single('avatar')(req, res, (err) => {
    if (err) return res.status(400).json({ error: err.message });
    res.json({ url: `/uploads/${req.file.filename}` });
  });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Quick Reference

Method Use Case
upload.single('field') One file from one input
upload.array('field', n) Multiple files, one input, max n
upload.fields([...]) Files from multiple named inputs
upload.none() Text fields only, reject files
multer({ dest }) Quick setup, no filename control
multer({ storage }) Full control over name and destination

What's Next?

This setup works great for local development and small projects. When you're ready to scale:

  • Move to cloud storage — use multer-s3 for AWS S3 or multer-google-storage for GCP

  • Stream directly — avoid writing to disk at all using memoryStorage()

  • Use a CDN — serve your static files faster globally

But for most projects? A well-configured local Multer setup with diskStorage is more than enough to ship with.

More from this blog