How to Handle File Uploads in React with a Node.js Multer Backend

Almost every React app eventually needs an upload form: avatars, invoices, ID documents, CSV imports. And almost every tutorial on React Node.js file upload with Multer stops at the same place: a basic form, multer({ dest: 'uploads/' }), and a happy “File uploaded!” message. dev.to has covered this at length.

That code works on localhost. In production it is a liability. It accepts .exe files renamed to .png, writes attacker-controlled filenames straight to disk, fills the server volume when someone uploads a 4 GB video, and gives the browser zero feedback while a large file crawls up a mobile connection.

This guide builds the whole feature end to end, then fixes everything the shorter tutorials leave out.

What you will build

  • A React drag-and-drop uploader with a real progress bar and a cancel button (no third-party UI library required)
  • An Express + Multer endpoint with disk storage, size limits, file count limits and strict MIME validation
  • Filename sanitizing and UUID-based storage names to kill path traversal and overwrite attacks
  • Magic-byte verification so a renamed executable is rejected even when the declared MIME type looks fine
  • A clean Multer error handler that returns useful JSON instead of an HTML stack trace
  • A path to stream large files to S3 instead of parking them on local disk

Versions used in this tutorial

Package Version Notes
Node.js 22 LTS or 24 Native crypto.randomUUID() and ESM
Express 5.x Async errors propagate automatically
Multer 2.x Do not use 1.4.4 or older, it carries known advisories
React 19.x Vite or Next.js client component both fine
file upload code

Why Multer, and how it actually works

Browsers send file uploads as multipart/form-data. Express does not parse that format: express.json() and express.urlencoded() both ignore it, which is why req.body and req.file come back undefined for beginners.

Multer is Node.js middleware built on top of busboy. It streams the multipart body, hands each part to a storage engine, and then populates:

  • req.file for upload.single('field')
  • req.files for upload.array('field', max) or upload.fields([...])
  • req.body for the non-file text fields sent alongside

Three storage engines matter in practice:

Engine Where bytes go Use it when
diskStorage Local filesystem Single server, files processed then moved
memoryStorage RAM as a Buffer Small files only (thumbnails, CSV under a few MB)
multer-s3 S3 / R2 / MinIO Multiple instances, containers, large media

Never use memoryStorage as a default. Ten concurrent 200 MB uploads is 2 GB of heap, and a single container will be OOM-killed long before that.

Step 1: the Express + Multer backend

Install

mkdir upload-api && cd upload-api
npm init -y
npm i express multer cors file-type express-rate-limit

Add "type": "module" to package.json so the ESM imports below work.

The Multer configuration

Here is the core of it. Read the comments, because each one blocks a specific attack.

// upload.js
import multer from "multer";
import path from "node:path";
import crypto from "node:crypto";
import fs from "node:fs";

const UPLOAD_DIR = path.resolve("storage/uploads");
fs.mkdirSync(UPLOAD_DIR, { recursive: true });

// Allowlist, never a denylist. Map MIME -> the extension WE choose.
const ALLOWED_TYPES = new Map([
  ["image/jpeg", ".jpg"],
  ["image/png", ".png"],
  ["image/webp", ".webp"],
  ["application/pdf", ".pdf"]
]);

const storage = multer.diskStorage({
  destination(req, file, cb) {
    cb(null, UPLOAD_DIR);
  },
  filename(req, file, cb) {
    // The original name is attacker input. We do not reuse it on disk.
    const ext = ALLOWED_TYPES.get(file.mimetype) ?? "";
    cb(null, crypto.randomUUID() + ext);
  }
});

function fileFilter(req, file, cb) {
  if (!ALLOWED_TYPES.has(file.mimetype)) {
    const err = new multer.MulterError("LIMIT_UNEXPECTED_FILE", file.fieldname);
    err.message = `Unsupported file type: ${file.mimetype}`;
    return cb(err);
  }
  cb(null, true);
}

export const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: 10 * 1024 * 1024, // 10 MB per file
    files: 5,                   // max 5 files per request
    fields: 10,                 // max 10 text fields
    parts: 20,                  // total parts guard
    fieldNameSize: 100,         // stops absurd field names
    headerPairs: 200
  }
});

The route, with magic-byte verification

file.mimetype comes from the Content-Type header the client sent. It is a hint, not a fact. Anyone with curl can claim a shell script is image/png. So after Multer has written the file, we inspect the real bytes.

// server.js
import express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import rateLimit from "express-rate-limit";
import { fileTypeFromFile } from "file-type";
import { upload } from "./upload.js";

const app = express();
app.use(cors({ origin: "http://localhost:5173" }));

const uploadLimiter = rateLimit({ windowMs: 60_000, limit: 20 });

const SAFE_SNIFFED = new Set(["jpg", "png", "webp", "pdf"]);

app.post("/api/uploads", uploadLimiter, upload.array("files", 5), async (req, res, next) => {
  try {
    if (!req.files?.length) {
      return res.status(400).json({ error: "No files received" });
    }

    const accepted = [];
    for (const file of req.files) {
      const sniffed = await fileTypeFromFile(file.path);
      if (!sniffed || !SAFE_SNIFFED.has(sniffed.ext)) {
        await fs.unlink(file.path); // delete the impostor immediately
        continue;
      }
      accepted.push({
        id: file.filename,
        size: file.size,
        type: sniffed.mime,
        // keep the display name in the DB, never on disk
        displayName: sanitizeDisplayName(file.originalname)
      });
    }

    if (!accepted.length) {
      return res.status(415).json({ error: "File content did not match an allowed type" });
    }

    res.status(201).json({ files: accepted });
  } catch (err) {
    next(err);
  }
});

function sanitizeDisplayName(name = "") {
  return path
    .basename(name)                       // strips ../../ and C:\ prefixes
    .replace(/[^\w.\- ]+/g, "_")          // drops control chars, quotes, RTL overrides
    .slice(0, 120);
}

A Multer error handler that returns real JSON

Without this, a file that is 1 byte over the limit produces a 500 with an HTML body, and your React catch block shows “Unexpected token <“.

import multer from "multer";

const MULTER_MESSAGES = {
  LIMIT_FILE_SIZE: "File is larger than the 10 MB limit",
  LIMIT_FILE_COUNT: "Too many files, 5 maximum",
  LIMIT_UNEXPECTED_FILE: "Unexpected or unsupported file",
  LIMIT_PART_COUNT: "Too many parts in the request"
};

app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError) {
    return res.status(413).json({
      code: err.code,
      error: err.message || MULTER_MESSAGES[err.code] || "Upload rejected"
    });
  }
  console.error(err);
  res.status(500).json({ error: "Upload failed" });
});

app.listen(4000, () => console.log("Upload API on :4000"));

Quick sanity check with curl

curl -F "[email protected]" http://localhost:4000/api/uploads
curl -F "[email protected];type=image/png" http://localhost:4000/api/uploads

The first returns 201. The second passes fileFilter (the header lies) but is destroyed by the magic-byte check and returns 415. That second request is exactly what most tutorials accept.

Step 2: the React drag-and-drop uploader

Why XMLHttpRequest and not fetch

This surprises people: fetch() still has no portable upload progress event. Request streams exist but require HTTP/2, duplex mode and are not supported everywhere. For a reliable progress bar in 2026, XMLHttpRequest.upload.onprogress remains the pragmatic choice. It also gives you xhr.abort() for free.

The component

// FileUploader.jsx
import { useRef, useState } from "react";

const MAX_BYTES = 10 * 1024 * 1024;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];

export default function FileUploader() {
  const [queue, setQueue] = useState([]);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState("idle");
  const [message, setMessage] = useState("");
  const [dragging, setDragging] = useState(false);
  const inputRef = useRef(null);
  const xhrRef = useRef(null);

  function addFiles(fileList) {
    const incoming = Array.from(fileList);
    const valid = [];
    const errors = [];

    for (const file of incoming) {
      if (!ACCEPTED.includes(file.type)) {
        errors.push(`${file.name}: type not allowed`);
      } else if (file.size > MAX_BYTES) {
        errors.push(`${file.name}: over 10 MB`);
      } else {
        valid.push(file);
      }
    }

    setMessage(errors.join(" / "));
    setQueue((prev) => [...prev, ...valid].slice(0, 5));
  }

  function onDrop(e) {
    e.preventDefault();
    setDragging(false);
    addFiles(e.dataTransfer.files);
  }

  function removeAt(index) {
    setQueue((prev) => prev.filter((_, i) => i !== index));
  }

  function startUpload() {
    if (!queue.length) return;

    const form = new FormData();
    queue.forEach((file) => form.append("files", file));
    form.append("folder", "invoices"); // extra fields land in req.body

    const xhr = new XMLHttpRequest();
    xhrRef.current = xhr;
    xhr.open("POST", "http://localhost:4000/api/uploads");
    xhr.withCredentials = true;

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) {
        setProgress(Math.round((event.loaded / event.total) * 100));
      }
    };

    xhr.onload = () => {
      let payload = {};
      try { payload = JSON.parse(xhr.responseText); } catch {}
      if (xhr.status >= 200 && xhr.status < 300) {
        setStatus("done");
        setMessage(`${payload.files?.length ?? 0} file(s) stored`);
        setQueue([]);
      } else {
        setStatus("error");
        setMessage(payload.error || `Upload failed (${xhr.status})`);
      }
    };

    xhr.onerror = () => { setStatus("error"); setMessage("Network error"); };
    xhr.onabort = () => { setStatus("idle"); setProgress(0); setMessage("Cancelled"); };

    setStatus("uploading");
    setProgress(0);
    xhr.send(form); // no Content-Type header, the browser sets the boundary
  }

  return (
    <div className="uploader">
      <div
        className={dragging ? "dropzone dropzone--active" : "dropzone"}
        onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        onClick={() => inputRef.current?.click()}
        role="button"
        tabIndex={0}
      >
        <p>Drag files here, or click to browse</p>
        <small>JPG, PNG, WEBP, PDF. 10 MB max, 5 files max.</small>
        <input
          ref={inputRef}
          type="file"
          multiple
          hidden
          accept={ACCEPTED.join(",")}
          onChange={(e) => addFiles(e.target.files)}
        />
      </div>

      <ul>
        {queue.map((file, i) => (
          <li key={file.name + i}>
            {file.name} ({(file.size / 1024).toFixed(0)} KB)
            <button type="button" onClick={() => removeAt(i)}>remove</button>
          </li>
        ))}
      </ul>

      {status === "uploading" && (
        <>
          <progress value={progress} max="100" /> <span>{progress}%</span>
          <button type="button" onClick={() => xhrRef.current?.abort()}>Cancel</button>
        </>
      )}

      <button type="button" disabled={!queue.length || status === "uploading"} onClick={startUpload}>
        Upload
      </button>

      {message && <p className={status === "error" ? "msg msg--error" : "msg"}>{message}</p>}
    </div>
  );
}

Three React mistakes that cost hours

  1. Setting the Content-Type header manually. If you write "Content-Type": "multipart/form-data", the boundary token is missing and Multer sees an empty body. Let the browser set it.
  2. The field name must match. form.append("files", file) in React has to match upload.array("files") in Express, otherwise Multer throws LIMIT_UNEXPECTED_FILE.
  3. Appending text fields after the files. Multer parses parts in order, so a field appended after the files is not yet in req.body inside diskStorage.filename(). If your storage path depends on a field, append that field first.
file upload code

Step 3: the security pitfalls most tutorials skip

This is the part that separates a demo from something you can ship.

1. Never write the original filename to disk

file.originalname can contain ../../etc/cron.d/x, a null byte, a 400-character name, a Unicode right-to-left override that makes gpj.exe render as exe.jpg, or simply the same name another user already uploaded. Generate the stored name yourself with crypto.randomUUID() and keep the human-readable name in your database. If you truly must reuse it, run it through path.basename() plus a strict character allowlist. Uploading files with ReactJS and NodeJS tackles the same question from another angle.

2. Derive the extension from your allowlist, not from the upload

Do not do path.extname(file.originalname). That happily writes .php, .jsp, .sh, .html or the classic double extension invoice.pdf.exe. Map the validated type to a fixed extension you control, as in the ALLOWED_TYPES map above.

3. Validate content, not headers

Multer’s fileFilter only sees the declared MIME type. Sniff the magic bytes afterwards with file-type, and for images consider re-encoding through sharp, which both strips EXIF metadata and destroys any polyglot payload:

import sharp from "sharp";
await sharp(file.path).rotate().resize({ width: 2000, withoutEnlargement: true })
  .toFormat("webp", { quality: 82 }).toFile(`${file.path}.webp`);

4. Treat SVG as executable code

An SVG is XML and can carry <script>, event handlers and external references. Served from your own origin, it is stored XSS. Either exclude SVG entirely, or sanitize it server side with DOMPurify in JSDOM before storing it.

5. Store uploads outside the web root

Do not point express.static() at your upload directory. Serve files through a controller that checks authorization and sets defensive headers:

res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Content-Disposition", `attachment; filename="${safeName}"`);

6. Enforce limits in three places

Layer Control Why
Reverse proxy client_max_body_size 12m; in Nginx Rejects the body before it reaches Node
Multer limits.fileSize, files, parts Aborts the stream mid-flight
React file.size check and accept UX only, trivially bypassed

7. Clean up and rate limit

  • When validation fails after Multer wrote the file, fs.unlink it. Otherwise rejected junk accumulates forever.
  • Handle req.on("aborted") to delete partial files from cancelled uploads.
  • Authenticate before Multer runs. Put your auth middleware before upload.single() so anonymous requests never get to write bytes.
  • Rate limit the endpoint per user, and run an antivirus pass (ClamAV via clamscan) if you accept documents from the public.

Step 4: streaming large files to S3 instead of local disk

Local disk breaks the moment you run two containers: the file is on instance A, the next request hits instance B. There are two good patterns.

Option A: multer-s3 (proxy through your API)

Bytes stream from the request straight to S3 without ever hitting the filesystem or buffering in RAM.

npm i multer-s3 @aws-sdk/client-s3
import { S3Client } from "@aws-sdk/client-s3";
import multerS3 from "multer-s3";
import multer from "multer";
import crypto from "node:crypto";

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

export const uploadToS3 = multer({
  storage: multerS3({
    s3,
    bucket: process.env.S3_BUCKET,
    // no ACL: keep the bucket private and serve via signed URLs
    contentType: multerS3.AUTO_CONTENT_TYPE,
    key(req, file, cb) {
      const ext = ALLOWED_TYPES.get(file.mimetype) ?? "";
      cb(null, `u/${req.user.id}/${crypto.randomUUID()}${ext}`);
    },
    metadata(req, file, cb) {
      cb(null, { original: encodeURIComponent(file.originalname) });
    }
  }),
  fileFilter,
  limits: { fileSize: 50 * 1024 * 1024, files: 5 }
});

Keep the bucket private, block public access at the account level, and generate short-lived GetObject signed URLs for reads. Note the trade-off: with this pattern you cannot magic-byte check before the object exists, so validate after the fact and delete on failure.

Option B: presigned uploads (browser talks to S3 directly)

For files above roughly 100 MB, or on serverless platforms with request size and duration caps, stop proxying. Your API only issues a signed instruction; the browser does the transfer.

  1. React asks POST /api/uploads/sign with the filename, size and type.
  2. The API authorizes the user, validates size and type, generates a key, and returns a presigned POST with policy conditions (content-length-range, starts-with on the key, allowed content type).
  3. React uploads to S3 with XMLHttpRequest so you keep the same progress bar.
  4. An S3 event notification or a client confirmation call triggers validation, virus scanning and thumbnail generation, then marks the record as ready.
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";

const { url, fields } = await createPresignedPost(s3, {
  Bucket: process.env.S3_BUCKET,
  Key: `u/${req.user.id}/${crypto.randomUUID()}.jpg`,
  Conditions: [
    ["content-length-range", 1, 50 * 1024 * 1024],
    ["eq", "$Content-Type", "image/jpeg"]
  ],
  Expires: 60
});

Choosing between them

Criterion Multer disk multer-s3 Presigned
Setup effort Low Medium Higher
Horizontal scaling No Yes Yes
Server bandwidth cost High High None
Validate before storing Yes Partial After upload
Good up to ~25 MB ~100 MB Multi-GB

Production checklist

  1. Multer 2.x installed, npm audit clean
  2. Auth and rate limiting run before the Multer middleware
  3. limits set for fileSize, files, fields and parts
  4. MIME allowlist in fileFilter plus magic-byte verification after write
  5. Stored filenames are UUIDs with a server-chosen extension
  6. Original filename sanitized and kept in the database only
  7. Upload directory outside the web root, or a private bucket
  8. Downloads served with nosniff, a sandbox CSP and Content-Disposition: attachment
  9. Rejected, partial and cancelled uploads are deleted
  10. Proxy body-size limit aligned with the Multer limit
  11. MulterError handler returning JSON, with matching React messages
  12. Images re-encoded to strip metadata, SVG excluded or sanitized

FAQ

How do I use Multer to upload a file in Node.js?

Install it with npm install multer, create an instance with a storage engine and limits, then attach it as route middleware: app.post("/api/uploads", upload.single("file"), handler). Multer parses the multipart/form-data body and exposes the result on req.file, with any text fields on req.body.

Why is req.file undefined in my Express route?

Four usual causes: the React field name does not match the name passed to single() or array(); you set a manual Content-Type header and killed the multipart boundary; you used upload.array() and should read req.files; or another body parser consumed the stream before Multer.

How do I upload multiple files with React and Multer?

Add multiple to the input, append every file under the same key with FormData.append("files", file), and use upload.array("files", 5) server side. For different fields such as an avatar and a document together, use upload.fields([{ name: "avatar", maxCount: 1 }, { name: "doc", maxCount: 3 }]).

Can I show upload progress with fetch instead of XMLHttpRequest?

Not reliably yet. Request-body streaming with duplex: "half" requires HTTP/2 and is not supported across all browsers, so XMLHttpRequest.upload.onprogress is still the dependable way to drive a progress bar, and it gives you cancellation through abort().

Is checking the MIME type in fileFilter enough?

No. The MIME type is supplied by the client and can be forged in one curl flag. Combine an allowlist in fileFilter with magic-byte inspection after the write, a server-chosen file extension, and ideally re-encoding for images.

What is the maximum file size Multer can handle?

Multer itself has no hard ceiling, but it is not the right tool for very large uploads because the bytes flow through your Node process. Keep diskStorage under about 25 MB, multer-s3 under about 100 MB, and switch to presigned direct-to-S3 uploads with multipart above that.

Should I use Multer or presigned URLs?

Use Multer when you need to inspect, resize or transform the file server side and the sizes are modest. Use presigned URLs when files are large, when you run serverless, or when you want the transfer cost and bandwidth off your own infrastructure.

Wrapping up

A working React Node.js file upload with Multer takes about twenty lines. A safe one takes a bit more: an allowlist you control, UUID filenames, limits at every layer, magic-byte verification, cleanup of rejected files, and a storage target that survives a second container.

Copy the configuration above, work through the checklist, and you have an upload pipeline that will pass a security review instead of becoming the incident report. Need a second pair of eyes on your upload or storage architecture? Get in touch with our engineering team.

Leave a Comment