File Uploads in Node.js with Multer
So you're building a form that lets users upload a profile photo, a resume, or maybe multiple images at once. You write your Express route, hit submit — and nothing works. No file. Just an empty req.body.
That's because file uploads don't work like regular form fields. And that's exactly what this guide is about.
We'll go from "why does this even need middleware" to "I have a working upload endpoint" — step by step.
1. Why File Uploads Need Middleware
When a regular HTML form submits text data, it sends it as application/x-www-form-urlencoded — a simple key=value string. Express can parse that out of the box.
But when a form includes a file input, the browser switches to a completely different format: multipart/form-data.
Here's what that actually means:
Instead of a flat string, the browser splits the request body into multiple parts, each separated by a boundary string. Every part has its own headers (like Content-Disposition and Content-Type) and a body — which for files is raw binary data.
A simplified look at what the request body looks like over the wire:
--boundary123
Content-Disposition: form-data; name="username"
john_doe
--boundary123
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
<binary data here>
--boundary123--
Express has no idea how to read this by default. It doesn't parse binary streams. That's why you need middleware — specifically, something that can intercept the request, parse the multipart body, extract the file, and hand it to your route handler in a usable form.
That middleware is Multer.
2. What Multer Is
Multer is an official, well-maintained Node.js middleware for handling multipart/form-data. It's built on top of busboy, a streaming HTML form data parser.
When Multer intercepts a request, here's what it does internally:
Incoming Request (multipart/form-data)
│
▼
┌─────────────┐
│ Multer │ ← parses the boundary, reads each part
└──────┬──────┘
│
┌─────┴──────┐
│ │
▼ ▼
Text fields File buffers
→ req.body → req.file / req.files
│
▼
Write to disk (or memory)
Install it:
npm install multer
3. Handling a Single File Upload
Let's start with the most common case — a user uploads one file.
Your HTML form:
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" />
<button type="submit">Upload</button>
</form>
⚠️
enctype="multipart/form-data"is non-negotiable. Without it, the file never reaches your server as binary data.
Your Express route:
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
const upload = multer({ dest: 'uploads/' }); // files go into /uploads folder
app.post('/upload', upload.single('avatar'), (req, res) => {
// upload.single('avatar') → matches the input name="avatar"
console.log(req.file); // the uploaded file's metadata
console.log(req.body); // any other text fields in the form
res.send('File uploaded successfully!');
});
app.listen(3000);
After a successful upload, req.file looks like this:
{
"fieldname": "avatar",
"originalname": "photo.jpg",
"encoding": "7bit",
"mimetype": "image/jpeg",
"destination": "uploads/",
"filename": "3f2a91bc74e1d",
"path": "uploads/3f2a91bc74e1d",
"size": 204800
}
Notice that Multer renames the file by default (the filename field above). It strips the extension entirely. We'll fix that in the storage configuration section.
4. Handling Multiple File Uploads
Two variations here: multiple files from one input, or files from multiple different inputs.
Multiple files from one field
<input type="file" name="photos" multiple />
// Accept up to 5 files from the "photos" field
app.post('/upload-many', upload.array('photos', 5), (req, res) => {
console.log(req.files); // array of file objects
res.send(`${req.files.length} files uploaded`);
});
req.files is now an array of the same file objects you saw in req.file.
Files from multiple different fields
<input type="file" name="resume" />
<input type="file" name="coverLetter" />
const cpUpload = upload.fields([
{ name: 'resume', maxCount: 1 },
{ name: 'coverLetter', maxCount: 1 }
]);
app.post('/apply', cpUpload, (req, res) => {
console.log(req.files['resume']); // array with 1 file
console.log(req.files['coverLetter']); // array with 1 file
});
5. Storage Configuration Basics
Using dest: 'uploads/' is quick but limited — you can't control the filename or folder structure. For real control, use multer.diskStorage().
const storage = multer.diskStorage({
// Where to save the file
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
// What to name the file
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname); // e.g. ".jpg"
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
// result: avatar-1714823910432-593820193.jpg
}
});
const upload = multer({ storage });
Why the random suffix? If two users upload profile.jpg at the same time, one will overwrite the other. The suffix makes filenames collision-safe.
Adding File Type Validation
You almost always want to restrict what file types are accepted:
const upload = multer({
storage,
limits: {
fileSize: 2 * 1024 * 1024 // 2MB max
},
fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|webp/;
const extValid = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimeValid = allowedTypes.test(file.mimetype);
if (extValid && mimeValid) {
cb(null, true); // accept
} else {
cb(new Error('Only image files are allowed'));
}
}
});
Checking both extension and mimetype matters. A user could rename
malware.exetomalware.jpg— checking only the extension would let it through.
6. Serving Uploaded Files
Saving files is only half the story. You need to expose them over HTTP so the client can actually view or download them.
// Serve everything in the /uploads folder as static files
app.use('/uploads', express.static('uploads'));
Now a file saved at uploads/avatar-1714823910432.jpg is accessible at:
http://localhost:3000/uploads/avatar-1714823910432.jpg
To send back the URL after an upload:
app.post('/upload', upload.single('avatar'), (req, res) => {
const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;
res.json({ url: fileUrl });
});
Handling Errors Gracefully
Multer throws specific errors you should catch:
app.post('/upload', (req, res) => {
upload.single('avatar')(req, res, function (err) {
if (err instanceof multer.MulterError) {
// e.g. file too large
return res.status(400).json({ error: err.message });
} else if (err) {
// custom fileFilter error
return res.status(400).json({ error: err.message });
}
// success
res.json({ file: req.file });
});
});
Putting It All Together
Here's a complete, minimal working example:
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) => {
const unique = Date.now() + path.extname(file.originalname);
cb(null, unique);
}
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
/image\/(jpeg|png|webp)/.test(file.mimetype)
? cb(null, true)
: cb(new Error('Images only'));
}
});
app.use('/uploads', express.static('uploads'));
app.post('/upload', (req, res) => {
upload.single('avatar')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
res.json({ url: `/uploads/${req.file.filename}` });
});
});
app.listen(3000, () => console.log('Server running on port 3000'));
Quick Reference
| Method | Use Case |
|---|---|
upload.single('field') |
One file from one input |
upload.array('field', n) |
Multiple files, one input, max n |
upload.fields([...]) |
Files from multiple named inputs |
upload.none() |
Text fields only, reject files |
multer({ dest }) |
Quick setup, no filename control |
multer({ storage }) |
Full control over name and destination |
What's Next?
This setup works great for local development and small projects. When you're ready to scale:
Move to cloud storage — use
multer-s3for AWS S3 ormulter-google-storagefor GCPStream directly — avoid writing to disk at all using
memoryStorage()Use a CDN — serve your static files faster globally
But for most projects? A well-configured local Multer setup with diskStorage is more than enough to ship with.