Learn how to accept file uploads in Express 5 with Multer, choose disk or memory storage, limit file size and type, and handle upload errors safely.

Bishal Biswas
Self Learner
What you will learn
- Why file uploads need a different body parser than JSON
- How to accept one file, many files, or a mix of files and text fields with Multer
- How to control where files are saved and what they are named
- How to limit file size and allow only certain file types
- How to handle upload errors and serve uploaded files back to users
Why uploads are different
So far, your clients have sent data as JSON, and express.json() read it for you. Files are different. A photo is binary data, not text, and a form often sends a file and some text fields together.
Browsers solve this with a special body format called multipart/form-data. “Multipart” means the body is split into several parts, one per form field. Each part has its own small header saying its name and, for files, its file name and type.
Think of it like a parcel with several boxes inside. One box holds a note (“display name: Asha”), another holds a photo. Someone has to open the parcel, unpack each box and put things in the right place. express.json() cannot open this kind of parcel. Multer can.
POST /profile (Content-Type: multipart/form-data; boundary=----abc)
------abc
Content-Disposition: form-data; name="displayName"
Asha
------abc
Content-Disposition: form-data; name="avatar"; filename="me.png"
Content-Type: image/png
<binary bytes of the image>
------abc--
The boundary is a random string the browser picks to mark where one part ends and the next begins. You never write this by hand, but it helps to know what Multer is reading.
Installing Multer
Multer is a middleware that reads multipart/form-data bodies. It puts text fields on req.body and file information on req.file (one file) or req.files (many files).
npm install multer
This installs Multer 2, which this lesson uses.
Your first upload
Here is the smallest useful example. It accepts one file from a form field named avatar.
// app.js
import express from 'express';
import multer from 'multer';
const app = express();
// Save uploaded files into the "uploads" folder (Multer creates it if needed)
const upload = multer({ dest: 'uploads/' });
app.post('/profile', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please attach a file in the "avatar" field' });
}
res.status(201).json({
message: 'Upload complete',
displayName: req.body.displayName,
file: req.file,
});
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Notice that Multer is not added with app.use(). You attach it only to the route that expects a file. upload.single('avatar') means “expect exactly one file, in the field called avatar”.
Test it with curl. The -F flag sends multipart/form-data, and @ means “read this file from disk”:
curl -F "displayName=Asha" -F "avatar=@./me.png" http://localhost:3000/profile
The response looks something like this:
{
"message": "Upload complete",
"displayName": "Asha",
"file": {
"fieldname": "avatar",
"originalname": "me.png",
"encoding": "7bit",
"mimetype": "image/png",
"destination": "uploads/",
"filename": "3f9c1e0b7a5d4c2e9b8a7f6e5d4c3b2a",
"path": "uploads/3f9c1e0b7a5d4c2e9b8a7f6e5d4c3b2a",
"size": 48213
}
}
If you prefer a browser, this HTML form sends the same request. The enctype attribute is what switches the form to multipart:
<form action="/profile" method="post" enctype="multipart/form-data">
<input type="text" name="displayName" placeholder="Display name" />
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</form>
Common mistake: Forgetting
enctype="multipart/form-data"on the form. Without it, the browser sends only the file name as text, andreq.fileisundefined.
What is inside req.file
| Property | Meaning |
|---|---|
fieldname |
The form field name, such as avatar |
originalname |
The file name on the user’s computer. Never trust it for saving files. |
mimetype |
The type the client claims, such as image/png |
size |
Size in bytes |
destination, filename, path |
Where the file was saved (disk storage only) |
buffer |
The whole file in memory (memory storage only) |
By default Multer gives saved files a random name with no extension. That is a safe choice: a user cannot pick a name like ../../server.js and overwrite your code.
Choosing where files go
The dest option is quick, but you usually want more control. Multer has two storage engines, which are plug-ins that decide what happens to each file.
| Storage | Where the file goes | Good for |
|---|---|---|
multer.diskStorage() |
A folder on your server’s disk | Small apps, files you serve yourself |
multer.memoryStorage() |
A Buffer in RAM (req.file.buffer) |
Small files you process or send to cloud storage |
Disk storage with safe file names
This version keeps a random name but adds the correct extension, so browsers know how to display the file.
// upload.js
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import multer from 'multer';
export const UPLOAD_DIR = 'uploads';
// With diskStorage you must create the folder yourself
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Map allowed types to the extension we will save
const EXTENSIONS = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
};
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ext = EXTENSIONS[file.mimetype] ?? '';
cb(null, `${randomUUID()}${ext}`);
},
});
export const uploadImage = multer({ storage });
The cb (callback) argument follows the old Node.js style: the first value is an error (or null), the second is the result.
Memory storage
Memory storage keeps the file in RAM. Nothing touches your disk, which is handy when you only want to resize an image or pass it on to a cloud service such as Amazon S3.
// upload-memory.js
import multer from 'multer';
export const uploadToMemory = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1 * 1024 * 1024 }, // 1 MB, keep memory uploads small
});
Warning: Every file in memory storage sits in your server’s RAM until the request ends. Without a size limit, a few large uploads at once can crash your server. Always set
limits.fileSizewith memory storage.
Many files and mixed fields
single() is one of four methods. Pick the one that matches your form.
| Method | Accepts | Result |
|---|---|---|
upload.single('avatar') |
One file in avatar |
req.file |
upload.array('photos', 5) |
Up to 5 files in photos |
req.files (an array) |
upload.fields([{ name: 'cover', maxCount: 1 }, { name: 'gallery', maxCount: 8 }]) |
Several named file fields | req.files.cover, req.files.gallery |
upload.none() |
Text fields only, no files | req.body |
Here is an album route that takes up to five photos:
// routes/albums.js
import { Router } from 'express';
import { uploadImage } from '../upload.js';
const router = Router();
router.post('/', uploadImage.array('photos', 5), (req, res) => {
const files = req.files ?? [];
res.status(201).json({
title: req.body.title,
photos: files.map((file) => `/uploads/${file.filename}`),
});
});
export default router;
Send several files by repeating the field name:
curl -F "title=Holiday" -F "photos=@./beach.jpg" -F "photos=@./hills.jpg" http://localhost:3000/albums
Note: If a client sends a file in a field you did not list, Multer rejects the request with a
LIMIT_UNEXPECTED_FILEerror. This protects you from surprise uploads.
Limiting size and type
Never accept “any file of any size”. Multer gives you two tools.
limitsstops the upload when it gets too big.fileSizeis in bytes. You can also capfiles(how many files) andfields(how many text fields).fileFilteris a function that runs for each file before it is saved. Callcb(null, true)to accept,cb(null, false)to silently skip, orcb(error)to reject the whole request.
Add a filter to upload.js and replace the old uploadImage export with this one:
// upload.js (add the filter, replace the uploadImage export)
const fileFilter = (req, file, cb) => {
if (EXTENSIONS[file.mimetype]) {
cb(null, true);
} else {
const error = new Error('Only PNG, JPEG and WebP images are allowed');
error.status = 400;
cb(error);
}
};
export const uploadImage = multer({
storage,
fileFilter,
limits: {
fileSize: 2 * 1024 * 1024, // 2 MB per file
files: 5,
},
});
Warning:
file.mimetypecomes from the client, so a user can lie about it. A file filter stops honest mistakes, not attackers. For important apps, check the real file contents after upload, for example with thefile-typepackage, which reads the first bytes of the file (its “magic number”).
Handling upload errors
When something goes wrong, Multer calls next(err), so the error lands in your error-handling middleware. Multer’s own problems are instances of multer.MulterError, and each has a code.
err.code |
When it happens |
|---|---|
LIMIT_FILE_SIZE |
A file is bigger than limits.fileSize |
LIMIT_FILE_COUNT |
Too many files |
LIMIT_UNEXPECTED_FILE |
A file arrived in a field you did not expect |
LIMIT_FIELD_COUNT |
Too many text fields |
Add a branch for them in your error handler:
// middleware/error-handler.js
import multer from 'multer';
export const errorHandler = (err, req, res, next) => {
if (res.headersSent) return next(err);
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message, code: err.code });
}
const status = err.status ?? 500;
res.status(status).json({
error: status < 500 ? err.message : 'Something went wrong',
});
};
413 means Payload Too Large, the standard status for “your upload is too big”. Try it by sending a file over the limit:
curl -F "avatar=@./huge-photo.jpg" http://localhost:3000/profile
{ "error": "File too large", "code": "LIMIT_FILE_SIZE" }
Serving uploaded files
Files on disk are not public until you serve them. You already know the tool for that: express.static.
// app.js
import express from 'express';
import { uploadImage, UPLOAD_DIR } from './upload.js';
import { errorHandler } from './middleware/error-handler.js';
const app = express();
app.use('/uploads', express.static(UPLOAD_DIR));
app.post('/profile', uploadImage.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please attach an image in the "avatar" field' });
}
res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
app.use(errorHandler);
app.listen(3000, (error) => {
if (error) throw error;
console.log('Server running on http://localhost:3000');
});
Now http://localhost:3000/uploads/<filename>.png shows the image.
Tip: In production, many teams send uploads straight to object storage (Amazon S3, Cloudflare R2, Google Cloud Storage) instead of the server’s disk. Server disks can be wiped on redeploy, and several servers do not share one disk.
Cleaning up after failures
Multer saves the file before your route handler runs. If your handler then fails, say the database is down, the file stays on disk with nothing pointing to it. Delete it yourself:
// routes/profile.js
import fs from 'node:fs/promises';
import { Router } from 'express';
import { uploadImage } from '../upload.js';
const router = Router();
router.post('/', uploadImage.single('avatar'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Please attach an image in the "avatar" field' });
}
try {
await saveAvatarInDatabase(req.file.filename); // your own function
res.status(201).json({ url: `/uploads/${req.file.filename}` });
} catch (err) {
await fs.unlink(req.file.path).catch(() => {}); // ignore "file already gone"
throw err; // Express 5 forwards this to the error handler
}
});
Common mistakes
Common mistake: Using
app.use(upload.any())for the whole app. This lets anyone upload files to any route. Attach Multer only to routes that need it, with the exact field names.
- Field name mismatch.
upload.single('avatar')but the form field is calledphoto. You get aLIMIT_UNEXPECTED_FILEerror. - Expecting
req.bodyon non-multipart requests. Multer only runs formultipart/form-data. For other requests it does nothing, and in Express 5req.bodystaysundefinedunless another parser ran. - Saving with
originalname. Two users uploadphoto.jpgand one overwrites the other, or a crafted name escapes your folder. Generate your own names. - No limits. Always set
fileSize, and keep memory storage small. - Putting uploads inside a folder that is served with
express.staticand allowing HTML or SVG files. A user could upload a page with scripts that runs on your domain. Allow only the types you need.
Key takeaways
- File uploads use multipart/form-data, which
express.json()cannot read. Multer can. - Use
single,array,fieldsornoneon the specific route, never globally. - Text fields land on
req.body, files onreq.fileorreq.files. - Choose disk storage for files you keep, memory storage for small files you process or forward.
- Always set
limitsand afileFilter, generate your own file names, and handleMulterErrorin your error handler.
Practice
- Build a
POST /avatarroute that accepts one PNG or JPEG up to 500 KB and returns its public URL. Test it withcurl -F. - Send a 1 MB file and confirm you get a
413withLIMIT_FILE_SIZE. - Create a
POST /productsroute withupload.fields()that accepts onecoverimage and up to fourgalleryimages, plus anametext field. - Switch the avatar route to memory storage and log
req.file.buffer.length. Does it matchreq.file.size?
What’s next
Uploads often come from a frontend running on a different address than your API, and browsers block that by default. Learn how to allow it safely in CORS Configuration.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready