Skip to main content

Command Palette

Search for a command to run...

Handling File Uploads in Express with Multer

Updated
13 min read
C
Software developer passionate about building scalable web applications with React and backend technologies. I enjoy solving problems, building projects, and sharing my learning with the community.

Accepting a text field in an Express form is straightforward — req.body.email and you're done. But the moment a user uploads a file, everything changes. The data arriving at your server is no longer simple key-value pairs. It's a binary stream mixed with form fields, chunked and encoded in a way that express.json() and express.urlencoded() have no idea how to handle.

This post explains why files are different, what Multer does about it, and how to handle single uploads, multiple uploads, storage configuration, and serving files back — all locally, no cloud services.


Why file uploads need their own middleware

When a browser submits a regular HTML form, the data is encoded as application/x-www-form-urlencoded — essentially a flat string of key-value pairs like name=Aarav&email=aarav@example.com. Express's built-in express.urlencoded() middleware parses this perfectly.

Files are different. You can't encode a binary image file as a URL-encoded string. So browsers use a different encoding called multipart/form-data.

In multipart/form-data, the request body is split into multiple "parts", each separated by a unique boundary string. Each part has its own headers describing what the field is and, for files, what the filename and content type are. A file upload request body looks roughly like this:

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

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

Aarav
------WebKitFormBoundary7MA4YWx
Content-Disposition: form-data; name="avatar"; filename="profile.jpg"
Content-Type: image/jpeg

[binary image data here]
------WebKitFormBoundary7MA4YWx--

express.json() and express.urlencoded() skip this entirely — they only handle their specific content types. You need middleware that understands multipart encoding, reads the binary stream, separates the parts, and gives you the file data in a usable form. That's what Multer does.


What Multer is

Multer is a Node.js middleware for handling multipart/form-data requests — specifically the ones that contain file uploads. It's built on top of busboy, a streaming multipart parser, and it integrates directly with Express's middleware system.

When a request containing a file hits your route, Multer intercepts it before your handler runs. It reads the binary stream, extracts each file, saves it according to your storage configuration, and then makes the file metadata available on req.file (for a single file) or req.files (for multiple files). Your route handler receives the request only after Multer has finished processing the upload.

The upload lifecycle looks like this:

Your route handler doesn't run until Multer has fully processed the upload. If Multer encounters an error — wrong file type, file too large — it can reject the request before your handler is even called.


Setup

Install Express and Multer:

npm init -y
npm install express multer

Create the basic folder structure:

project/
├── uploads/        ← where uploaded files are saved
├── public/         ← static HTML for testing
│   └── index.html
└── server.js

Create the uploads folder:

mkdir uploads
mkdir public

Basic Multer configuration

Before handling any routes, you create and configure a Multer instance. The simplest configuration specifies where to save uploaded files:

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

const app = express();

// Basic disk storage — saves files to the /uploads folder
const upload = multer({ dest: "uploads/" });

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

upload is now a Multer middleware factory. You use it to create specific middleware for specific routes by calling methods on it: upload.single(), upload.array(), upload.fields().

With dest: "uploads/", Multer saves files with a randomly generated name and no extension. That's fine for getting started but not ideal for production. We'll fix it with proper storage configuration later.


Handling a single file upload

Let's build a profile picture upload. First the HTML form, then the route.

The HTML form

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>File Upload</title>
</head>
<body>

  <h2>Upload Profile Picture</h2>
  <form action="/upload/single" method="POST" enctype="multipart/form-data">
    <label>Username: <input type="text" name="username"></label><br><br>
    <label>Avatar: <input type="file" name="avatar" accept="image/*"></label><br><br>
    <button type="submit">Upload</button>
  </form>

</body>
</html>

Two things to notice:

  • enctype="multipart/form-data" is mandatory. Without it, the browser sends application/x-www-form-urlencoded and the file data never reaches the server properly.

  • The name attribute on the file input (name="avatar") must match what you pass to upload.single() in the route.

The route

// server.js (continued)

// Serve the HTML form
app.use(express.static("public"));

// Single file upload — field name must match the form's input name
app.post("/upload/single", upload.single("avatar"), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: "No file uploaded." });
  }

  console.log("File info:", req.file);
  console.log("Text fields:", req.body);

  res.json({
    message: "File uploaded successfully",
    username: req.body.username,
    file: {
      originalName: req.file.originalname,
      savedAs:      req.file.filename,
      size:         req.file.size,
      mimeType:     req.file.mimetype,
      path:         req.file.path
    }
  });
});

upload.single("avatar") tells Multer to expect one file in a field named avatar. It populates req.file with:

{
  fieldname:    "avatar",          // the form field name
  originalname: "profile.jpg",     // original filename from the user's machine
  encoding:     "7bit",
  mimetype:     "image/jpeg",      // file type
  destination:  "uploads/",        // where it was saved
  filename:     "a1b2c3d4e5f6...", // the generated name on disk (no extension)
  path:         "uploads/a1b2c3...",
  size:         84320              // size in bytes
}

Text fields from the same form (username in this case) are available on req.body as usual.


Handling multiple file uploads

There are two scenarios for multiple files: several files in the same field (like a gallery upload), or files in different fields (like a form with separate fields for a CV and a cover letter).

Multiple files in one field: upload.array()

<!-- Add to index.html -->
<h2>Upload Multiple Photos</h2>
<form action="/upload/gallery" method="POST" enctype="multipart/form-data">
  <label>Gallery: <input type="file" name="photos" accept="image/*" multiple></label><br><br>
  <button type="submit">Upload Gallery</button>
</form>

The multiple attribute on the input lets users select several files at once.

// server.js
// upload.array("fieldname", maxCount)
app.post("/upload/gallery", upload.array("photos", 10), (req, res) => {
  if (!req.files || req.files.length === 0) {
    return res.status(400).json({ error: "No files uploaded." });
  }

  const fileInfo = req.files.map(file => ({
    originalName: file.originalname,
    savedAs:      file.filename,
    size:         file.size,
    mimeType:     file.mimetype
  }));

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

upload.array("photos", 10) accepts up to 10 files from the field named photos. The second argument is a limit — Multer will reject the upload if more files are sent. req.files is an array of file objects with the same structure as req.file for single uploads.

Multiple files from different fields: upload.fields()

<!-- Add to index.html -->
<h2>Job Application</h2>
<form action="/upload/application" method="POST" enctype="multipart/form-data">
  <label>CV:          <input type="file" name="cv"></label><br><br>
  <label>Cover Letter: <input type="file" name="coverLetter"></label><br><br>
  <button type="submit">Apply</button>
</form>
// server.js
app.post(
  "/upload/application",
  upload.fields([
    { name: "cv",          maxCount: 1 },
    { name: "coverLetter", maxCount: 1 }
  ]),
  (req, res) => {
    const cv          = req.files["cv"]?.[0];
    const coverLetter = req.files["coverLetter"]?.[0];

    if (!cv || !coverLetter) {
      return res.status(400).json({ error: "Both CV and cover letter are required." });
    }

    res.json({
      message: "Application received",
      cv: {
        name: cv.originalname,
        size: cv.size
      },
      coverLetter: {
        name: coverLetter.originalname,
        size: coverLetter.size
      }
    });
  }
);

upload.fields() takes an array of field definitions. req.files becomes an object keyed by field name — req.files["cv"] is an array (even for maxCount: 1), so you get the file with [0].


Storage configuration: controlling how files are saved

The dest option is convenient but limited — files get random names and no extensions. For anything real, you want multer.diskStorage().

Custom disk storage

// server.js — replace the basic upload config

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    // cb(error, destination folder)
    cb(null, "uploads/");
  },

  filename: function (req, file, cb) {
    // Build a unique filename with the original extension
    const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname); // ".jpg", ".pdf", etc.
    cb(null, file.fieldname + "-" + uniqueSuffix + ext);
  }
});

const upload = multer({ storage });

Now uploaded files are saved with meaningful names:

avatar-1714392847123-492834756.jpg
cv-1714392851234-839274019.pdf

file.fieldname is the HTML input name. Date.now() and a random number together make collisions essentially impossible. path.extname(file.originalname) preserves the original extension.

Controlling the destination folder per field

You can save different types of uploads to different folders:

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    if (file.mimetype.startsWith("image/")) {
      cb(null, "uploads/images/");
    } else if (file.mimetype === "application/pdf") {
      cb(null, "uploads/documents/");
    } else {
      cb(null, "uploads/misc/");
    }
  },
  filename: function (req, file, cb) {
    const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
    const ext = path.extname(file.originalname);
    cb(null, file.fieldname + "-" + uniqueSuffix + ext);
  }
});

Make sure those sub-folders exist before the server starts:

const fs = require("fs");

["uploads/images", "uploads/documents", "uploads/misc"].forEach(dir => {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }
});

File filtering: accepting only certain types

You almost always want to restrict what types of files users can upload. Multer provides a fileFilter option for this:

function imageFilter(req, file, cb) {
  const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];

  if (allowedTypes.includes(file.mimetype)) {
    cb(null, true);   // accept the file
  } else {
    cb(new Error("Only image files are allowed (JPEG, PNG, GIF, WebP)"), false);
  }
}

const upload = multer({
  storage,
  fileFilter: imageFilter,
  limits: {
    fileSize: 5 * 1024 * 1024 // 5MB maximum file size
  }
});

The fileFilter callback takes three arguments:

  • req — the request

  • file — the file being uploaded (metadata only, not contents)

  • cb — a callback: cb(null, true) to accept, cb(error, false) to reject

The limits option lets you set maximum file size (in bytes), maximum number of files, maximum field sizes, and more. Setting sensible limits prevents users from uploading 500MB videos to your server.


Handling Multer errors

Multer errors — file too large, wrong type, too many files — need to be handled differently from regular Express errors. They're instances of multer.MulterError.

app.post("/upload/single", (req, res, next) => {
  upload.single("avatar")(req, res, function (err) {
    if (err instanceof multer.MulterError) {
      // A Multer-specific error occurred
      if (err.code === "LIMIT_FILE_SIZE") {
        return res.status(400).json({ error: "File is too large. Maximum size is 5MB." });
      }
      if (err.code === "LIMIT_UNEXPECTED_FILE") {
        return res.status(400).json({ error: "Unexpected field name." });
      }
      return res.status(400).json({ error: err.message });
    } else if (err) {
      // A different error (like our custom fileFilter error)
      return res.status(400).json({ error: err.message });
    }

    // No error — file uploaded successfully
    if (!req.file) {
      return res.status(400).json({ error: "No file uploaded." });
    }

    res.json({ message: "Uploaded", file: req.file.filename });
  });
});

This pattern wraps the Multer middleware in a callback-style invocation so errors flow through your handler rather than crashing to the global error handler unexpectedly.


Serving uploaded files

Once a file is saved in your uploads/ folder, you need a way for clients to access it. The easiest approach is serving the folder as static files:

// Serve uploaded files statically
app.use("/uploads", express.static("uploads"));

After this, any file in uploads/ is accessible at http://localhost:3000/uploads/filename.jpg.

In your upload handler, you can return the public URL:

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

  const fileUrl = `http://localhost:3000/uploads/${req.file.filename}`;

  res.json({
    message: "Upload successful",
    url: fileUrl
  });
});

The client receives the URL and can display the image or link to the file immediately.

Controlled file access

Serving uploads/ as static means anyone who knows the filename can access it. If your uploads should be private (user documents, private photos), don't use express.static. Instead, handle downloads through a route that checks authentication first:

app.get("/files/:filename", (req, res) => {
  // Check if user is authenticated and has permission
  if (!req.session?.userId) {
    return res.status(401).json({ error: "Unauthorised" });
  }

  const filePath = path.join(__dirname, "uploads", req.params.filename);
  res.sendFile(filePath);
});

This way the file is only served if your code explicitly allows it.


Complete working example

Here's the full server with everything combined — storage config, file filtering, size limits, single and multiple upload routes, error handling, and file serving:

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

const app = express();

// Ensure upload directories exist
["uploads/images", "uploads/documents"].forEach(dir => {
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});

// Storage configuration
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    const dest = file.mimetype.startsWith("image/")
      ? "uploads/images"
      : "uploads/documents";
    cb(null, dest);
  },
  filename: (req, file, cb) => {
    const unique = Date.now() + "-" + Math.round(Math.random() * 1e9);
    cb(null, file.fieldname + "-" + unique + path.extname(file.originalname));
  }
});

// File filter
const fileFilter = (req, file, cb) => {
  const allowed = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
  allowed.includes(file.mimetype)
    ? cb(null, true)
    : cb(new Error("Only JPEG, PNG, WebP images and PDFs are allowed"), false);
};

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

// Serve HTML form and uploaded files
app.use(express.static("public"));
app.use("/uploads", express.static("uploads"));

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

    if (!req.file) {
      return res.status(400).json({ error: "No file received." });
    }

    res.json({
      message: "Uploaded successfully",
      url: `http://localhost:3000/uploads/\({req.file.destination.split("/")[1]}/\){req.file.filename}`
    });
  });
});

// Multiple files
app.post("/upload/multiple", (req, res) => {
  upload.array("files", 5)(req, res, err => {
    if (err instanceof multer.MulterError) {
      return res.status(400).json({ error: err.message });
    } else if (err) {
      return res.status(400).json({ error: err.message });
    }

    if (!req.files?.length) {
      return res.status(400).json({ error: "No files received." });
    }

    const uploaded = req.files.map(f => ({
      name: f.originalname,
      size: f.size,
      url:  `http://localhost:3000/uploads/\({f.destination.split("/")[1]}/\){f.filename}`
    }));

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

app.listen(3000, () => console.log("Server at http://localhost:3000"));

Common mistakes

Missing enctype on the form:

<!-- ❌ This sends application/x-www-form-urlencoded — Multer gets nothing -->
<form action="/upload" method="POST">

<!-- ✅ This sends multipart/form-data — Multer can read it -->
<form action="/upload" method="POST" enctype="multipart/form-data">

Mismatched field names:

// ❌ Form has name="avatar" but Multer is looking for "photo"
upload.single("photo")

// ✅ Field name matches the form input name
upload.single("avatar")

Trusting the MIME type alone for security:

file.mimetype comes from the browser — it can be spoofed. A malicious user can rename a PHP file to photo.jpg and the browser will send image/jpeg as the MIME type. For production uploads, also check the file extension and consider using a library that inspects the actual file bytes (magic numbers) to verify the real file type.

Not handling the case where req.file is undefined:

Multer doesn't throw an error if no file was submitted — req.file is simply undefined. Always check for it before trying to read req.file.filename.


What comes next

What you've built here is the local foundation. From here the natural next steps are:

  • Using memory storage instead of disk (multer({ storage: multer.memoryStorage() })) when you want to process the file in memory before sending it somewhere — useful for resizing images before saving, or streaming directly to S3.

  • Cloud storage — libraries like multer-s3 are drop-in Multer storage engines that save directly to AWS S3, Google Cloud Storage, or Cloudflare R2 instead of your local disk.

  • Image processing — combining Multer with sharp to resize, compress, and convert images on upload before saving the result.

All of those build on exactly what you've learned here. The multipart parsing, the storage configuration, the file filtering — it all works the same way regardless of where the file ultimately lands.


More from this blog

Why Node.js is Perfect for Building Fast Web Applications

Every technology makes a bet. Node.js's bet was this: most web applications aren't slow because they do too much computation. They're slow because they spend most of their time waiting — waiting for a database to respond, waiting for a file to load, waiting for an external API to return. If you build a runtime optimised around that specific reality, you get something genuinely fast for the work most web apps actually do.

May 9, 202611 min read1
C

Chetan Chauhan | Tech Blog | chetan71

41 posts