File Uploads in Express with Multer
Handling file uploads is one of those tasks that seems like it should be simple — the browser has a file input, the server should receive the file. But the mechanics of how file data travels over HTTP are different from how regular form data or JSON travels, and those differences require specific handling on the server.
This article covers why file uploads need special treatment, what Multer does, and how to handle single files, multiple files, storage configuration, and serving uploaded files — all with working examples.
Why File Uploads Are Different
When a browser submits a regular HTML form, the data is encoded as a URL-encoded string:
name=Alice&email=alice%40example.com&age=30
This works well for text. It does not work for binary data like images, PDFs, or videos. Binary data cannot be reliably encoded into a URL string without significant expansion or corruption.
For file uploads, browsers use a different encoding: multipart/form-data. Instead of a flat key-value string, the request body is divided into separate parts, each with its own headers and content. A form that uploads a profile picture alongside a name field looks something like this at the HTTP level:
Content-Type: multipart/form-data; boundary=----FormBoundary7MA4YWxkTrZu0gW
------FormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="name"
Alice
------FormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
(binary file data)
------FormBoundary7MA4YWxkTrZu0gW--
The boundary string separates each part. Each part has its own Content-Disposition header identifying the field name, and for files, a filename and Content-Type.
Express's built-in express.json() and express.urlencoded() middleware cannot parse this format. They are not designed for it. For multipart data, you need a dedicated parser — and Multer is the most widely used one.
What Multer Is
Multer is an Express middleware specifically for handling multipart/form-data requests. It reads the incoming multipart body, extracts each part, makes text fields available on req.body, and makes file data available on req.file (for single files) or req.files (for multiple files).
Behind the scenes, Multer uses busboy, a streaming HTML form data parser. It processes the request as a stream rather than buffering the entire body into memory, which matters for large files.
Install it with:
npm install multer
Upload Flow
Before writing any code, it helps to see the full lifecycle of an upload:
┌──────────────────────────────────────────────────────────────────────────┐
│ File Upload Lifecycle │
│ │
│ ┌─────────────┐ multipart/form-data ┌──────────────────────┐ │
│ │ Browser │ ──────────────────────────► │ Express Server │ │
│ │ │ POST /upload │ │ │
│ │ <form> │ Content-Type: │ ┌────────────────┐ │ │
│ │ <input │ multipart/form-data │ │ Multer │ │ │
│ │ type=file │ │ │ middleware │ │ │
│ │ /> │ │ │ │ │ │
│ └─────────────┘ │ │ Parses body │ │ │
│ │ │ Validates file │ │ │
│ │ │ Writes to disk │ │ │
│ │ │ (or memory) │ │ │
│ │ └───────┬────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌────────────────┐ │ │
│ │ │ Route Handler │ │ │
│ │ │ │ │ │
│ │ │ req.file → │ │ │
│ │ │ filename, │ │ │
│ │ │ path, size, │ │ │
│ │ │ mimetype │ │ │
│ │ │ │ │ │
│ │ │ req.body → │ │ │
│ │ │ text fields │ │ │
│ │ └───────┬────────┘ │ │
│ │ │ │ │
│ └──────────┼───────────┘ │
│ │ │
│ ┌──────────▼───────────┐ │
│ │ /uploads/ │ │
│ │ avatar-1716000.jpg │ │
│ │ report-1716001.pdf │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
Multer sits between the raw incoming request and your route handler. It does the parsing work, writes the file to disk (or memory), and by the time your handler runs, the file data is already accessible through the request object.
Basic Setup
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// Basic multer instance — stores files in memory by default
const upload = multer({ dest: 'uploads/' });
multer({ dest: 'uploads/' }) creates an upload handler that stores files in the uploads/ directory. The directory is created automatically if it does not exist. Files are given random names without extensions (we will handle extensions in the storage configuration section).
upload is not middleware itself — it is a factory that produces middleware based on what you need. upload.single(), upload.array(), and upload.fields() each return a middleware function tailored to a specific upload shape.
Handling a Single File Upload
Use upload.single(fieldName) where fieldName matches the name attribute of the file input in the HTML form.
<form action="/upload/avatar" method="POST" enctype="multipart/form-data">
<input type="text" name="username" placeholder="Username" />
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</form>
app.post('/upload/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
console.log(req.file);
// {
// fieldname: 'avatar',
// originalname: 'photo.jpg',
// encoding: '7bit',
// mimetype: 'image/jpeg',
// destination: 'uploads/',
// filename: 'a3f9b2c1d4e5', ← random name, no extension
// path: 'uploads/a3f9b2c1d4e5',
// size: 84231
// }
console.log(req.body);
// { username: 'Alice' }
res.json({
message: 'Upload successful',
filename: req.file.filename,
size: req.file.size,
});
});
req.file is populated with metadata about the uploaded file. req.body contains the text fields from the same form. Both are available together in the handler.
Handling Multiple File Uploads
For multiple files in a single field, use upload.array(fieldName, maxCount):
<form action="/upload/photos" method="POST" enctype="multipart/form-data">
<input type="file" name="photos" multiple accept="image/*" />
<button type="submit">Upload Photos</button>
</form>
app.post('/upload/photos', upload.array('photos', 5), (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const uploaded = req.files.map((file) => ({
filename: file.filename,
originalname: file.originalname,
size: file.size,
}));
res.json({
message: `${req.files.length} file(s) uploaded`,
files: uploaded,
});
});
req.files is an array of file objects with the same structure as req.file. The second argument to upload.array() sets the maximum number of files allowed — Multer rejects the upload if more are sent.
For forms with files in multiple distinct fields, use upload.fields():
app.post(
'/upload/document',
upload.fields([
{ name: 'resume', maxCount: 1 },
{ name: 'coverLetter', maxCount: 1 },
]),
(req, res) => {
const resume = req.files['resume']?.[0];
const coverLetter = req.files['coverLetter']?.[0];
res.json({
resume: resume?.originalname,
coverLetter: coverLetter?.originalname,
});
}
);
With upload.fields(), req.files becomes an object keyed by field name, each value an array of file objects.
Multer Middleware Execution Flow
POST /upload/avatar (multipart/form-data)
│
▼
┌─────────────────────────────────────────────┐
│ upload.single('avatar') │
│ │
│ 1. Check Content-Type is multipart │
│ → if not: skip (call next()) │
│ │
│ 2. Parse multipart body │
│ → extract text fields → req.body │
│ → extract file parts │
│ │
│ 3. Validate file │
│ → apply fileFilter (if configured) │
│ → check size limits (if configured) │
│ → if rejected: call next(error) │
│ │
│ 4. Write file to storage │
│ → DiskStorage: write to dest folder │
│ → MemoryStorage: hold in buffer │
│ │
│ 5. Attach to req │
│ → req.file = { filename, path, ... } │
│ │
│ 6. Call next() │
└──────────────────┬──────────────────────────┘
│
▼
Route Handler runs
req.file is ready
One thing worth noting: Multer calls next(error) when an upload is rejected (file too large, wrong type). This means your Express error-handling middleware catches Multer errors — you do not need try/catch in each route handler for validation failures.
Storage Configuration
The dest shorthand assigns random filenames without extensions. For production use, multer.diskStorage() gives you full control over the destination directory and filename.
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// cb(error, destination path)
cb(null, 'uploads/');
},
filename: (req, file, cb) => {
// Preserve the original extension
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `\({file.fieldname}-\){Date.now()}${ext}`;
// cb(error, filename)
cb(null, uniqueName);
},
});
const upload = multer({ storage });
Now uploaded files get names like avatar-1716000000000.jpg instead of a random hex string. The extension is preserved from the original filename using path.extname().
You can also organize files into subdirectories by field name or user ID:
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const dir = file.fieldname === 'avatar' ? 'uploads/avatars' : 'uploads/documents';
cb(null, dir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `\({Date.now()}-\){Math.round(Math.random() * 1e9)}${ext}`);
},
});
File Filtering
Rejecting files by type before they are written to disk:
const imageFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true); // accept the file
} else {
cb(new Error('Only JPEG, PNG, and WebP images are allowed'), false); // reject
}
};
const upload = multer({
storage,
fileFilter: imageFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
},
});
limits.fileSize sets the maximum file size in bytes. Multer enforces this automatically and calls next(error) with a MulterError if the limit is exceeded.
Serving Uploaded Files
Uploading files is only half the problem. Once stored, they need to be accessible at a URL. express.static() handles this directly:
// Files in the 'uploads/' directory are served at /uploads/*
app.use('/uploads', express.static('uploads'));
After this, a file at uploads/avatar-1716000000000.jpg is accessible at http://localhost:3000/uploads/avatar-1716000000000.jpg.
In a typical flow, you would store the filename in a database record and use it to construct the URL when serving the resource:
app.post('/users/avatar', upload.single('avatar'), async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const avatarUrl = `/uploads/${req.file.filename}`;
// Save the URL to the user's record in the database
await db.query('UPDATE users SET avatar_url = ? WHERE id = ?', [
avatarUrl,
req.user.id,
]);
res.json({ avatarUrl });
});
// Later, when fetching the user:
// { id: 1, name: 'Alice', avatarUrl: '/uploads/avatar-1716000000000.jpg' }
// Client constructs: http://yourapp.com/uploads/avatar-1716000000000.jpg
Path Security Note
Never use req.file.originalname directly as the filename on disk. Users can set an original filename to anything — including path traversal strings like ../../etc/passwd. Always generate the filename server-side (as shown in the storage configuration examples above) and treat originalname as display-only metadata.
Handling Multer Errors
Multer generates two types of errors. MulterError for things Multer itself caught (file too large, unexpected field), and regular Error for things like your fileFilter rejecting a file.
const multer = require('multer');
// Place this after your routes
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
// Multer-specific errors: LIMIT_FILE_SIZE, LIMIT_UNEXPECTED_FILE, etc.
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 5MB.' });
}
return res.status(400).json({ error: err.message });
}
if (err) {
// Custom errors from fileFilter or other middleware
return res.status(400).json({ error: err.message });
}
next();
});
This error handler distinguishes between Multer errors (which have a code property you can check) and general errors. It catches everything that Multer rejects via next(error) without requiring try/catch in each route.
A Complete Upload Example
Putting it together — a profile avatar upload endpoint with validation, custom storage, and error handling:
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/avatars'),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `avatar-\({Date.now()}\){ext}`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
allowed.includes(file.mimetype)
? cb(null, true)
: cb(new Error('Images only: JPEG, PNG, WebP'));
},
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
});
app.use('/uploads', express.static('uploads'));
app.post('/users/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({
message: 'Avatar uploaded',
url: `/uploads/avatars/${req.file.filename}`,
size: req.file.size,
});
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File exceeds 2MB limit' });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
Summary
Regular form data travels as URL-encoded text, which Express handles with express.urlencoded(). File data travels as multipart/form-data — a structured, multi-part format that requires a dedicated parser. Multer provides that parser as Express middleware.
Multer's middleware sits between the raw request and your route handler. It parses the multipart body, applies validation rules (file type, size), writes the file to disk or memory, and attaches the result to req.file or req.files. By the time your handler runs, the file is already stored and its metadata is ready to use.
The essential configuration decisions are storage (disk vs memory, destination directory, filename generation), filtering (which file types to accept), and size limits. For serving uploaded files, express.static() with the upload directory is sufficient for local development and simple deployments.
The natural next step from local file storage is moving to object storage — S3, Cloudflare R2, or similar. The Multer setup stays the same; the storage engine changes to a cloud-backed implementation like multer-s3.
Comments
Join the discussionNo comments yet. Be the first to comment.
