File Uploads in React with Node.js & Multer

File uploads are a core feature in many real-world applications — think profile pictures, CVs, product images, or document submissions. In this guide, you'll learn how to handle both single and multiple file uploads using React on the frontend and Node.js with Multer on the backend.
Whether you're a beginner getting to grips with FormData or an intermediate developer looking for a solid, production-ready pattern, this guide has you covered. We'll start with the theory so you truly understand what's happening under the hood, then build the full implementation step by step.
What is Multer — and What is multipart/form-data?
When you send a regular form — like a login form — the browser encodes the data as a simple key=value string: username=john&password=secret. The server reads this easily because it's just plain text.
Files are completely different. A file is binary data — bytes that represent an image, a PDF, a video. You can't convert that to a key=value string. Instead, the browser uses a special encoding format called multipart/form-data. In this format, the request body is split into multiple "parts", each separated by a unique boundary string. One part might be a text field, another part might be the binary contents of an image file. The server needs to know how to parse all these parts out of the raw request body.
This is exactly what Multer does. Multer is a Node.js middleware for Express that intercepts incoming requests with multipart/form-data encoding, reads each part, and hands you the files and text fields in a structured, easy-to-use format. Without Multer, Express has no idea how to handle file data — its built-in body parsers only understand JSON and URL-encoded text.
How FormData Works in the Browser
FormData is a built-in browser API that lets you construct a set of key/value pairs representing form fields and their values — including files. It's the JavaScript equivalent of an HTML form submission with enctype="multipart/form-data".
When you call new FormData() and append a file to it, the browser reads the actual binary contents of the file from the user's disk and packages it into the FormData object, ready to be sent as part of an HTTP request body.
One of the most important things to understand is the Content-Type header. When you pass a FormData object as the body of a fetch() request, the browser automatically sets the Content-Type header to multipart/form-data and appends a unique boundary string — something like: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW. This boundary string is what the server uses to separate the individual parts of the request body. If you manually override the Content-Type header in your fetch call, you strip out this boundary string and Multer can no longer parse the request. The upload silently fails.
How Files Are Stored on Disk — and Why Filenames Are Randomised
When Multer saves a file, it writes the binary data to a location on your server's filesystem. You control this via Multer's diskStorage engine, which lets you define two things: the destination folder and the filename.
You might wonder — why not just save the file with its original name? If a user uploads profile.jpg, why not save it as profile.jpg? There are three good reasons not to:
- Collisions — if two users both upload a file called profile.jpg, the second upload silently overwrites the first. You'd lose data with no warning.
- Security — a malicious user could craft a filename like ../../server.js and potentially overwrite files outside your uploads folder if paths aren't handled carefully.
- Predictability — if filenames are guessable, users could access each other's files by constructing the URL themselves.
The solution is to generate a unique filename on the server by combining a timestamp with a random number and keeping the original file extension. For example: file-1711234567890-483920174.jpg. This name is completely unpredictable, guaranteed unique, and still carries the correct extension so the browser knows how to render it.
The original filename isn't lost — Multer stores it in req.file.originalname so you can save it to your database alongside the generated name if you need to display it to users later.
Project Overview
Here's what we're building:
- A Node.js + Express backend with Multer configured for single and multiple file uploads
- Two API endpoints: POST /upload/single and POST /upload/multiple
- A React frontend with two forms that send files to the backend
- File type and size validation on the backend
- Clear error and success feedback in the UI
Part 1: Setting Up the Backend
Step 1: Initialise Your Node.js Project
mkdir file-upload-server
cd file-upload-server
npm init -ynpm install express multer cors- express — our web framework for handling HTTP requests and routing
- multer — the middleware that parses multipart/form-data and handles file saving
- cors — allows cross-origin requests from our React frontend running on a different port
Step 2: Configure Multer
Create a file called multerConfig.js. Separating this from your main server file keeps things clean and reusable — you can import the same upload instance into multiple route files as your project grows.
// multerConfig.js
const multer = require("multer");
const path = require("path");
// diskStorage gives us full control over where and how files are saved
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, "uploads/");
},
filename: function (req, file, cb) {
// Unique name: fieldname + timestamp + random number + original extension
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname); // e.g. ".jpg"
cb(null, file.fieldname + "-" + uniqueSuffix + ext);
},
});
// fileFilter runs before the file is saved — return an error to reject it
const fileFilter = (req, file, cb) => {
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Only JPEG, PNG, and WEBP images are allowed"), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max per file
},
});
module.exports = upload;Step 3: Create the Express Server
Multer is used as route-level middleware — it sits between the route path and your handler function, processing the file before your code runs. By the time your (req, res) handler executes, the file is already on disk and its metadata is available on req.file.
// server.js
const express = require("express");
const cors = require("cors");
const path = require("path");
const fs = require("fs");
const upload = require("./multerConfig");
const app = express();
const PORT = 5000;
app.use(cors({ origin: "http://localhost:5173" }));
// Auto-create the uploads folder on server start if it doesn't exist
const uploadsDir = path.join(__dirname, "uploads");
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir);
}
// Serve uploaded files statically so the React frontend can display them
app.use("/uploads", express.static(uploadsDir));
// ─────────────────────────────────────────
// ROUTE 1: Single File Upload
// upload.single("file") — looks for one file under the field name "file"
// ─────────────────────────────────────────
app.post("/upload/single", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded." });
}
res.status(200).json({
message: "File uploaded successfully!",
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
url: `http://localhost:${PORT}/uploads/${req.file.filename}`,
});
});
// ─────────────────────────────────────────
// ROUTE 2: Multiple File Uploads
// upload.array("files", 5) — accepts up to 5 files under the field name "files"
// ─────────────────────────────────────────
app.post("/upload/multiple", upload.array("files", 5), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: "No files uploaded." });
}
const fileData = req.files.map((file) => ({
filename: file.filename,
originalName: file.originalname,
size: file.size,
url: `http://localhost:${PORT}/uploads/${file.filename}`,
}));
res.status(200).json({
message: `${req.files.length} file(s) uploaded successfully!`,
files: fileData,
});
});
// ─────────────────────────────────────────
// Global Error Handler
// Must have four parameters (err, req, res, next) — otherwise Express won't
// recognise it as an error handler and Multer errors go unhandled
// ─────────────────────────────────────────
app.use((err, req, res, next) => {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(400).json({ error: "File too large. Maximum size is 5MB." });
}
if (err.message) {
return res.status(400).json({ error: err.message });
}
res.status(500).json({ error: "Something went wrong." });
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));Step 4: Run Your Backend
node server.js
# Server running on http://localhost:5000The Full Request Lifecycle — What Actually Happens When You Upload a File
Before building the frontend, it's worth mapping out exactly what happens from the moment a user clicks "Upload" to the moment the file lands on disk. Understanding this flow makes debugging much easier and helps you know exactly where to look when something goes wrong.
- 1. User selects a file — the browser makes it available as a File object via e.target.files. Nothing has been sent yet — the file is still only on the user's device.
- 2. React stores the File object in state — again, no network activity yet.
- 3. On submit, you create a new FormData() and append the file — the browser packages the binary data into a multipart/form-data body.
- 4. fetch() sends a POST request — the browser sets the correct Content-Type header including the boundary string automatically.
- 5. The request arrives at Express — without Multer, Express cannot read the body at all.
- 6. Multer's middleware intercepts the request — it reads the multipart body, extracts each file part, runs the fileFilter check, and if the file passes, writes the binary data to disk in your uploads/ folder.
- 7. Multer attaches file metadata to req.file (or req.files for multiple) — your route handler now runs with full access to the saved file's details.
- 8. Your handler responds with JSON — filename, size, and the public URL.
- 9. React receives the response and updates state — the user sees confirmation and can preview the file.
Part 2: Building the React Frontend
Step 5: Create Your React App
npm create vite@latest file-upload-client -- --template react
cd file-upload-client
npm install
npm run devStep 6: Single File Upload Component
When the user selects a file, we store the File object in state — we don't send anything yet. Only when they click Upload do we build the FormData and fire the request. This gives us control over timing and lets us show a loading state during the upload.
// src/components/SingleUpload.jsx
import { useState } from "react";
function SingleUpload() {
const [file, setFile] = useState(null);
const [status, setStatus] = useState("");
const [uploadedFile, setUploadedFile] = useState(null);
const [loading, setLoading] = useState(false);
const handleFileChange = (e) => {
setFile(e.target.files[0]);
setStatus("");
setUploadedFile(null);
};
const handleUpload = async () => {
if (!file) {
setStatus("Please select a file first.");
return;
}
const formData = new FormData();
formData.append("file", file); // "file" must match upload.single("file") on the backend
try {
setLoading(true);
const res = await fetch("http://localhost:5000/upload/single", {
method: "POST",
body: formData,
// No Content-Type header — the browser sets it automatically with the boundary string
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStatus(data.message);
setUploadedFile(data);
} catch (err) {
setStatus(`Error: ${err.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="upload-card">
<h2>Single File Upload</h2>
<input type="file" accept="image/*" onChange={handleFileChange} />
<button onClick={handleUpload} disabled={loading}>
{loading ? "Uploading..." : "Upload"}
</button>
{status && <p className="status">{status}</p>}
{uploadedFile && (
<div className="result">
<p><strong>Saved as:</strong> {uploadedFile.filename}</p>
<p><strong>Original name:</strong> {uploadedFile.originalName}</p>
<p><strong>Size:</strong> {(uploadedFile.size / 1024).toFixed(1)} KB</p>
<img src={uploadedFile.url} alt="Uploaded file" width={200} />
</div>
)}
</div>
);
}
export default SingleUpload;Step 7: Multiple File Upload Component
e.target.files returns a FileList — an array-like object, but not a real array. We convert it with Array.from() so we can use .map(), .length, and other array methods on it. We then loop over the files and append each one to FormData under the same field name "files". Multer's upload.array() collects all parts with that field name into req.files.
// src/components/MultipleUpload.jsx
import { useState } from "react";
function MultipleUpload() {
const [files, setFiles] = useState([]);
const [status, setStatus] = useState("");
const [uploadedFiles, setUploadedFiles] = useState([]);
const [loading, setLoading] = useState(false);
const handleFileChange = (e) => {
setFiles(Array.from(e.target.files)); // Convert FileList to a real array
setStatus("");
setUploadedFiles([]);
};
const handleUpload = async () => {
if (files.length === 0) {
setStatus("Please select at least one file.");
return;
}
if (files.length > 5) {
setStatus("Maximum 5 files allowed.");
return;
}
const formData = new FormData();
// Append each file under the same field name
// Multer's upload.array("files") collects them all into req.files
files.forEach((file) => formData.append("files", file));
try {
setLoading(true);
const res = await fetch("http://localhost:5000/upload/multiple", {
method: "POST",
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStatus(data.message);
setUploadedFiles(data.files);
} catch (err) {
setStatus(`Error: ${err.message}`);
} finally {
setLoading(false);
}
};
return (
<div className="upload-card">
<h2>Multiple File Upload</h2>
<p className="hint">Select up to 5 images (JPEG, PNG, WEBP — max 5MB each)</p>
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
{files.length > 0 && (
<p className="selected-count">{files.length} file(s) selected</p>
)}
<button onClick={handleUpload} disabled={loading}>
{loading ? "Uploading..." : "Upload All"}
</button>
{status && <p className="status">{status}</p>}
{uploadedFiles.length > 0 && (
<div className="result">
<h3>Uploaded Files:</h3>
{uploadedFiles.map((f, i) => (
<div key={i} className="uploaded-item">
<img src={f.url} alt={f.originalName} width={120} />
<p>{f.originalName} — {(f.size / 1024).toFixed(1)} KB</p>
</div>
))}
</div>
)}
</div>
);
}
export default MultipleUpload;Step 8: Wire Everything Into App.jsx
// src/App.jsx
import SingleUpload from "./components/SingleUpload";
import MultipleUpload from "./components/MultipleUpload";
import "./App.css";
function App() {
return (
<div className="app">
<h1>React File Uploads with Multer</h1>
<div className="uploads-wrapper">
<SingleUpload />
<MultipleUpload />
</div>
</div>
);
}
export default App;Common Mistakes to Avoid
These mistakes catch almost everyone the first time they implement file uploads. Knowing them in advance will save you hours of debugging:
- Setting Content-Type manually — never do this with FormData. The browser sets it automatically including the boundary string. Override it and Multer cannot parse the request.
- Mismatched field names — the string in formData.append() must exactly match the string in upload.single() or upload.array(). 'file' vs 'image' causes a silent failure where req.file is simply undefined.
- Forgetting to create the uploads/ folder — Multer throws an error on the first upload if the destination folder doesn't exist. Auto-create it on server start with fs.mkdirSync().
- Missing the global error handler — Multer errors like LIMIT_FILE_SIZE are forwarded to Express's error handling chain. Without a handler, your server returns a generic 500 with no useful message.
- Not checking req.file after upload — always verify req.file exists in your route handler. If the fileFilter rejected the file, req.file will be undefined even though no error was thrown.
Taking It Further
Local disk storage works well for development and small projects, but in production you'll typically want to store files in the cloud. The good news is that swapping the storage engine doesn't require any changes to your React code — only the Multer configuration on the backend changes.
The two most popular options are multer-s3 for AWS S3 (or compatible services like Cloudflare R2), and streaming directly to Cloudinary using their Node.js SDK with Multer's memory storage. With memory storage, Multer holds the file in a buffer in RAM rather than writing it to disk, and you pass that buffer directly to the cloud provider. This approach is cleaner for serverless or ephemeral environments where writing to disk isn't reliable.
Final Thoughts
File uploads feel complex at first because they span multiple layers — the browser's File API, multipart encoding, HTTP transport, server-side middleware, and filesystem access. But once you understand what each layer is responsible for, the implementation becomes straightforward and predictable.
The pattern you've built here — FormData on the frontend, Multer middleware on the backend, randomised filenames on disk — is the same foundation used in production applications everywhere. Master this and you're well-equipped to extend it to cloud storage, MongoDB references, and more advanced upload flows.
Want to see how to store the uploaded file URL in MongoDB, or how to stream files directly to Cloudinary? Drop a comment in LiveChat and I'll cover it in a follow-up guide.