Skip to main content

Command Palette

Search for a command to run...

Handling File Uploads in Express with Multer

Updated
6 min readView as Markdown
Handling File Uploads in Express with Multer

So at some point in almost every project, you need allow users to upload something it can be a profile picture, document, CSV and the first time you can try to handle that with just express.json() you can quickly realize that it doesn't work like that. Nothing shows up in req.body, req.file is undefined, and you're confused about it why.

The reason is that file uploads can work differently from regular JSON requests. Let me explain this why, and then show you how Multer fixes it.

Why File Uploads Need Special Handling

When you send JSON from a form or API call, the data goes as plain text in the request body. Express's built-in express.json() middleware knows how to read that.

But files are different. When a form submits with a file attached, the browser uses a special encoding called multipart/form-data. Instead of one blob of JSON, the request gets split into multiple parts one part for each field, one part for each file. Each part has its own headers, its own content, its own boundaries separating them.

express.json() has no idea what to do with that. It's built for JSON, not multipart data. So you need something specifically designed to parse multipart requests and pull out the files and that's exactly what Multer does.

What is Multer?

Multer is an Express middleware for handling multipart/form-data. Its whole job is to intercept file uploads, parse the multipart request, and give you access to the uploaded files through req.file (single upload) or req.files (multiple uploads).

It also handles where the file goes either saved to disk or kept in memory and gives you control over file naming, size limits, and file type filtering.

Install it first:

npm install multer

Basic setup:

const multer = require("multer")
const upload = multer({ dest: "uploads/" })

That dest option tells Multer where to save uploaded files. For now this is the simplest way to get going

The Upload Lifecycle

Before jumping into code, here's what actually happens when a file upload request hits your server:

By the time your route handler runs, Multer has already dealt with the file. You just use what it gives you.

Handling a Single File Upload

const express = require("express")
const multer = require("multer")

const app = express()
const upload = multer({ dest: "uploads/" })

app.post("/upload", upload.single("avatar"), (req, res) => {
  console.log(req.file)
  res.json({ message: "File uploaded", file: req.file })
})

app.listen(3000)

upload.single("avatar") is the middleware. The string "avatar" is the field name it has to match the name attribute of the file input in your form, or the field name in Postman.

Once the upload is done, req.file has all the info you need:

{
  fieldname: 'avatar',
  originalname: 'profile.jpg',
  encoding: '7bit',
  mimetype: 'image/jpeg',
  destination: 'uploads/',
  filename: 'a3f9c2d1b4e7...', 
  path: 'uploads/a3f9c2d1b4e7...',
  size: 204800
}

Notice filename Multer generates a random name by default and strips the extension. That's why storage configuration matters if you want readable filenames.

Handling Multiple File Uploads

Two ways to do this depending on what you need.

Multiple files from the same field:

app.post("/upload-many", upload.array("photos", 5), (req, res) => {
  console.log(req.files)
  res.json({ count: req.files.length, files: req.files })
})

upload.array("photos", 5) first argument is the field name, second is the max number of files allowed. Files come through as req.files an array.

Multiple files from different fields:

app.post("/upload-mixed", upload.fields([
  { name: "avatar", maxCount: 1 },
  { name: "resume", maxCount: 1 }
]), (req, res) => {
  console.log(req.files.avatar)
  console.log(req.files.resume)
  res.json({ message: "Both files received" })
})

upload.fields() takes an array of field configs. req.files is now an object where each key is a field name and the value is an array of file objects for that field.

Storage Configuration

The dest shortcut works fine for quick testing, but in real projects you want control over filenames and where exactly things go. Multer's diskStorage engine gives you that.

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "uploads/")
  },
  filename: (req, file, cb) => {
    const uniqueName = Date.now() + "-" + file.originalname;
    cb(null, uniqueName)
  }
})

const upload = multer({ storage })

destination controls which folder the file goes into. filename controls what the file is named on disk. That cb is a callback first argument is an error, second is the value you're setting.

Now instead of a random hash, your uploaded file gets a name like 1714823400000-profile.jpg. Much more manageable.

You can also add file size limits:

const upload = multer({
  storage,
  limits: { fileSize: 2 * 1024 * 1024 } // 2MB max
})

And filter by file type:

const upload = multer({
  storage,
  fileFilter: (req, file, cb) => {
    if (file.mimetype.startsWith("image/")) {
      cb(null, true)
    } else {
      cb(new Error("Only images allowed"), false)
    }
  }
});

cb(null, true) accepts the file. cb(null, false) rejects it silently. cb(new Error(...), false) rejects with an error you can catch.

Serving Uploaded Files

Once files are saved to disk, you need a way to actually serve them back. express.static() handles this.

app.use("/uploads", express.static("uploads"))

Now any file inside your uploads/ folder is accessible at http://localhost:3000/uploads/filename.jpg.

So after uploading, you can send back the file URL:

app.post("/upload", upload.single("avatar"), (req, res) => {
  const fileUrl = `http://localhost:3000/uploads/${req.file.filename}`
  res.json({ url: fileUrl })
})

Client stores that URL, uses it wherever profile picture, document preview, whatever the use case is.

Handling Upload Errors

Multer throws errors for things like file size exceeded or wrong file type. These need to be caught specifically because they're Multer errors, not generic Express errors.

app.post("/upload", (req, res) => {
  upload.single("avatar")(req, res, (err) => {
    if (err instanceof multer.MulterError) {
      return res.status(400).json({ message: err.message })
    } else if (err) {
      return res.status(400).json({ message: err.message })
    }
    res.json({ file: req.file })
  })
})

This inline style gives you direct access to the error before it hits your general error handler. multer.MulterError covers things like file too large, too many files, unexpected field. The generic err catch covers your custom fileFilter errors.

Full Example Together

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) => {
    cb(null, Date.now() + path.extname(file.originalname))
  }
})

const upload = multer({
  storage,
  limits: { fileSize: 2 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    file.mimetype.startsWith("image/")
      ? cb(null, true)
      : cb(new Error("Images only"), false)
  }
})

app.use("/uploads", express.static("uploads"))

app.post("/upload", upload.single("avatar"), (req, res) => {
  const url = `http://localhost:3000/uploads/${req.file.filename}`
  res.json({ message: "Uploaded", url })
})

app.listen(3000, () => console.log("Running on 3000"))

Storage config, size limit, type filter, static serving all in one place. This is the pattern you'd start a real upload feature with.

The first time file uploads work end-to-end in your own project is genuinely satisfying. Request comes in, file lands on disk, URL goes back to the client. Once you have this working locally, the next step would be replacing local disk storage with something like Cloudinary or S3 but that's a separate topic. Get comfortable with this pattern first.