Skip to content
Node.js nd libraries 5 min read

File Uploads with Multer

Browsers send uploaded files as multipart/form-data, a format Express does not parse out of the box — express.json() and express.urlencoded() both leave it untouched. Multer is the standard middleware that fills that gap: it parses multipart bodies, exposes uploaded files on req.file/req.files, and puts non-file fields on req.body. This page covers disk versus memory storage, file size and type limits, single versus multiple files, and how to persist uploads to disk or stream them on to cloud storage.

Installing and a first upload

Multer runs on any maintained Node.js release; Node 20 or 22 LTS is the sensible default. Install it alongside Express. It ships both ESM and CommonJS builds, so const multer = require("multer") works unchanged where you prefer require.

npm install express multer

You configure a Multer instance once, then apply it as route middleware. The .single("avatar") factory accepts the form field name and populates req.file for exactly one upload. By default Multer writes incoming files to the OS temp directory with a random name, so you almost always pass an explicit dest or storage.

import express from "express";
import multer from "multer";

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

app.post("/avatar", upload.single("avatar"), (req, res) => {
  // req.file is the uploaded file; req.body holds text fields
  res.json({
    field: req.file.fieldname,
    original: req.file.originalname,
    storedAt: req.file.path,
    bytes: req.file.size,
  });
});

app.listen(3000);

Output:

{
  "field": "avatar",
  "original": "profile.png",
  "storedAt": "uploads/3f9a1c2b8e4d",
  "bytes": 48213
}

Disk vs. memory storage

The dest shortcut is convenient but gives you no control over filenames. For real apps, configure a storage engine. Multer ships two: diskStorage writes each file straight to the filesystem, and memoryStorage keeps the whole file in a Buffer on req.file.buffer. Disk storage is the right default for large files and local persistence; memory storage suits small files you immediately forward to cloud storage, an image processor, or a database, since it avoids a temp-file round trip.

import multer from "multer";
import path from "node:path";
import crypto from "node:crypto";

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => {
    const ext = path.extname(file.originalname);
    cb(null, `${crypto.randomUUID()}${ext}`);
  },
});

export const diskUpload = multer({ storage });
export const memoryUpload = multer({ storage: multer.memoryStorage() });
AspectdiskStoragememoryStorage
File locationWritten to diskBuffer on req.file.buffer
req.file.pathPresentAbsent
Memory useLowWhole file held in RAM
Best forLarge files, local servingSmall files forwarded to cloud/DB
CleanupManual on failureAutomatic (GC)

Memory storage holds the entire file in RAM. A handful of concurrent multi-hundred-MB uploads can exhaust memory and crash the process — always pair it with a strict limits.fileSize.

Limits: size and file type

Never accept uploads unbounded. The limits object caps file size, field counts, and file counts before Multer buffers anything large. Validate the MIME type in fileFilter, which runs per file and can reject by calling cb(null, false) or surface an error with cb(new Error(...)).

const upload = multer({
  storage,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 MB per file
    files: 4,                  // max 4 files per request
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/png", "image/jpeg", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Only PNG, JPEG, or WebP images are allowed"));
    }
  },
});

When a limit is exceeded, Multer throws a MulterError (for example code: "LIMIT_FILE_SIZE"). Catch it in an error-handling middleware so clients get a clean 4xx instead of a stack trace.

import multer from "multer";

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(413).json({ error: err.code });
  }
  if (err) {
    return res.status(400).json({ error: err.message });
  }
  next();
});

Output:

HTTP 413
{ "error": "LIMIT_FILE_SIZE" }

The MIME type in fileFilter comes from the client and can be spoofed. For security-sensitive uploads, verify the real type from the file’s magic bytes (e.g. the file-type package) after the upload completes.

Single vs. multiple files

Multer exposes a small family of factories for different form shapes. .single handles one field; .array accepts many files under one field name; .fields mixes several named fields; and .none parses a multipart form that contains only text.

MethodPopulatesUse case
.single("avatar")req.fileOne file, one field
.array("photos", 5)req.files (array)Many files, same field, max 5
.fields([...])req.files (object keyed by field)Different named fields
.none()req.body onlyMultipart text, no files
// Multiple files under the same field name
app.post("/gallery", upload.array("photos", 5), (req, res) => {
  res.json({ count: req.files.length, names: req.files.map((f) => f.filename) });
});

// Distinct fields, each with its own limit
const mixed = upload.fields([
  { name: "cover", maxCount: 1 },
  { name: "screenshots", maxCount: 3 },
]);
app.post("/listing", mixed, (req, res) => {
  res.json({
    cover: req.files.cover?.[0].filename,
    shots: req.files.screenshots?.map((f) => f.filename) ?? [],
  });
});

Storing to disk or cloud

Disk storage is finished as soon as the handler runs — the file already lives at req.file.path, ready to serve via express.static or move into permanent storage. For cloud destinations, use memoryStorage and push the buffer to your provider, or use a community storage engine such as multer-s3 that streams directly to S3-compatible object storage.

import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION });

app.post("/upload", memoryUpload.single("file"), async (req, res) => {
  const key = `uploads/${crypto.randomUUID()}-${req.file.originalname}`;
  await s3.send(
    new PutObjectCommand({
      Bucket: process.env.S3_BUCKET,
      Key: key,
      Body: req.file.buffer,
      ContentType: req.file.mimetype,
    }),
  );
  res.status(201).json({ key });
});

Output:

HTTP 201
{ "key": "uploads/9b1c-profile.png" }

Best practices

  • Always configure an explicit storage engine; the default temp-dir behavior leaks files and gives you no control over filenames.
  • Set limits.fileSize (and limits.files) on every upload route so a malicious client cannot exhaust disk or memory.
  • Generate server-side filenames with crypto.randomUUID() — never trust originalname for the path, which can carry traversal sequences.
  • Validate MIME types in fileFilter, and re-check magic bytes after upload for anything security-sensitive.
  • Use memoryStorage only for small files you forward immediately; prefer diskStorage or streaming engines for large uploads.
  • Add an error-handling middleware that maps MulterError codes to clean HTTP responses.
  • Apply Multer only to the specific routes that accept uploads, not globally, so other endpoints are unaffected.
Last updated June 14, 2026
Was this helpful?