File Uploads in Express: Why They Do Not Just Work Out of the Box

I had a form with a file input. I had an Express server with a POST route. I hit submit, opened req.body in my route handler, and got... nothing. The text fields were there. The file was not. I spent twenty minutes convinced my form was broken before someone in a Discord server said "Express does not parse files by default."
That was the moment I realized file uploads are a different kind of HTTP request, and your server needs help handling them.
Why your server cannot read files from a normal form
When you submit a regular HTML form with text fields, the browser encodes the data as application/x-www-form-urlencoded. That is the default. It looks like a query string: name=Priya&email=priya@example.com. Express's built-in express.json() and express.urlencoded() middleware can parse this without any trouble.
Files do not work that way. A file is binary data. It could be an image, a PDF, a video. You cannot flatten that into a query string. So the browser switches to a different encoding called multipart/form-data. The request body gets split into multiple parts, each separated by a randomly generated boundary string. One part might be a text field, another part is the raw file bytes with metadata like the original filename and MIME type.
Express has no built-in parser for multipart data. It sees the request come in, does not recognize the encoding, and skips it. That is why req.body was empty. The data was there in the request. Express just did not know how to read it.
<!-- this form sends multipart/form-data -->
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="text" name="caption" />
<input type="file" name="photo" />
<button type="submit">Upload</button>
</form>
Without enctype="multipart/form-data", the browser sends the filename as a string but not the actual file contents. I made this mistake twice before it stuck.
What Multer does
Multer is a Node.js middleware built specifically for handling multipart/form-data. It parses the incoming request, extracts the file(s), saves them wherever you tell it to, and attaches file metadata to req.file or req.files so your route handler can use it.
You install it like any other npm package:
npm install multer
Multer only processes multipart forms. If someone sends a regular JSON request to an endpoint that uses Multer, it ignores the request and passes it along unchanged. It does not interfere with your other routes.
Handling a single file upload
The simplest case: one file input, one upload. Multer gives you a function called upload.single('fieldname') that handles this. The field name must match the name attribute on your HTML file input.
const express = require('express');
const multer = require('multer');
const app = express();
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('photo'), (req, res) => {
console.log(req.file);
console.log(req.body); // text fields still work
res.json({ file: req.file });
});
app.listen(3000);
When you submit the form, Multer saves the file to the uploads/ folder and populates req.file with an object like this:
{
fieldname: 'photo',
originalname: 'vacation.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: 'uploads/',
filename: 'a1b2c3d4e5f6', // random name, no extension
path: 'uploads/a1b2c3d4e5f6',
size: 245920 // bytes
}
Notice the filename. Multer strips the original name and extension, replacing it with a random hex string. This is a security measure. If Multer kept the original filename, someone could upload a file called ../../etc/passwd or malicious.html and potentially cause problems depending on how you serve it. The random name prevents path traversal and naming conflicts.
The uploads/ directory needs to exist before you start the server. Multer does not create it for you. If the folder is missing, the upload fails with an error. Add this near the top of your server file:
const fs = require('fs');
const path = require('path');
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
Handling multiple file uploads
Two scenarios here. First: multiple files from the same input field. Second: multiple file inputs with different names.
For the same field, use upload.array():
// accepts up to 5 files from a single input named "photos"
app.post('/gallery', upload.array('photos', 5), (req, res) => {
console.log(req.files); // array of file objects
console.log(req.files.length); // how many were uploaded
res.json({ count: req.files.length });
});
<input type="file" name="photos" multiple />
The second argument to upload.array() is the maximum number of files. If someone tries to send 10 files when you set the limit to 5, Multer rejects the entire request.
For different fields, use upload.fields():
app.post('/profile', upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'resume', maxCount: 1 },
]), (req, res) => {
console.log(req.files['avatar'][0]); // single file object
console.log(req.files['resume'][0]); // single file object
res.json({ message: 'got both files' });
});
With upload.fields(), req.files is an object where each key is the field name and each value is an array of file objects for that field. Even if maxCount is 1, you still get an array, so you access the file at index [0].
Controlling where and how files get saved
The dest: 'uploads/' shorthand works, but you get random filenames with no extensions. For anything beyond a quick prototype, you want more control. Multer's diskStorage engine lets you set the destination folder and the filename separately.
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
// keep original extension, prefix with timestamp to avoid collisions
const uniqueName = Date.now() + '-' + file.originalname;
cb(cb, uniqueName);
},
});
const upload = multer({ storage: storage });
Wait, there is a bug in that code. I left it in on purpose because I made this exact mistake when I first wrote a storage config. The filename callback should be cb(null, uniqueName), not cb(cb, uniqueName). The first argument is an error (or null if no error). Passing the callback function itself as the error is a silent failure that produces a confusing error message about callbacks not being a function. Here is the corrected version:
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
const uniqueName = Date.now() + '-' + file.originalname;
cb(null, uniqueName);
},
});
const upload = multer({ storage: storage });
Now a file called vacation.jpg gets saved as something like 1717843200000-vacation.jpg. You keep the extension, avoid naming collisions, and can still identify the original file.
You should also filter what types of files you accept. Without a filter, someone can upload anything, executables included. Add a fileFilter function:
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB max
fileFilter: function (req, file, cb) {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, and WebP images are allowed'));
}
},
});
The limits option caps file size. The fileFilter checks the MIME type. If the file fails either check, Multer throws an error before writing anything to disk.
Handle Multer errors in your route so the client gets a readable response instead of a stack trace:
app.post('/upload', (req, res) => {
upload.single('photo')(req, res, function (err) {
if (err instanceof multer.MulterError) {
// Multer-specific error (file too large, too many files, etc.)
return res.status(400).json({ error: err.message });
}
if (err) {
// your custom error from fileFilter
return res.status(400).json({ error: err.message });
}
// no error, file is in req.file
res.json({ file: req.file });
});
});
Serving uploaded files back to users
Files sitting in an uploads/ folder are useless unless users can access them. The simplest way is to serve the folder as static files:
app.use('/uploads', express.static('uploads'));
Now a file saved as uploads/1717843200000-vacation.jpg is accessible at http://localhost:3000/uploads/1717843200000-vacation.jpg. You can use this URL in an <img> tag or send it back in an API response.
app.post('/upload', upload.single('photo'), (req, res) => {
const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;
res.json({
message: 'uploaded',
url: fileUrl,
});
});
A word about production. Serving files directly from your Node server works for learning and small projects. Once you have real users and real traffic, you move uploaded files to a dedicated storage service like AWS S3 or Google Cloud Storage, and serve them through a CDN. The Node server should not be responsible for serving static files at scale. But that is a separate topic, and when you are starting out, express.static is the right tool.
A quick tip about .gitignore
Add your uploads folder to .gitignore. You do not want user-uploaded files committed to your repository. It bloats the repo, and if any of those files contain personal data, you have a privacy problem baked into your version history.
# .gitignore
uploads/
Keep the folder structure in your repo by adding an empty .gitkeep file inside uploads/ so Git tracks the directory without tracking its contents.
The full working example
Here is everything together in one file. In a real project you would split this into separate modules, but for learning it helps to see it all in one place.
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const app = express();
// make sure uploads/ exists
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
// storage config
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads/');
},
filename: function (req, file, cb) {
cb(null, Date.now() + '-' + file.originalname);
},
});
// upload instance with limits and filter
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: function (req, file, cb) {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, and WebP images are allowed'));
}
},
});
// serve uploaded files
app.use('/uploads', express.static('uploads'));
// single file upload
app.post('/upload', upload.single('photo'), (req, res) => {
const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;
res.json({ url: fileUrl });
});
// multiple files from same field
app.post('/gallery', upload.array('photos', 5), (req, res) => {
const urls = req.files.map(f =>
`\({req.protocol}://\){req.get('host')}/uploads/${f.filename}`
);
res.json({ urls });
});
app.listen(3000, () => console.log('running on port 3000'));
What to take away from this
Express does not handle file uploads on its own. The browser sends files using multipart/form-data, which is a different encoding than what Express parses by default. Multer fills that gap.
For a single file, use upload.single(). For multiple files from one input, use upload.array(). For files from different inputs, use upload.fields(). Always configure a fileFilter and a fileSize limit, even in development, because building that habit early saves you from accepting unexpected files later.
Start with express.static for serving uploads. Move to cloud storage when your project outgrows a single server. And keep your uploads out of Git.






