File uploads
Accept files on a router or your own route, store them in S3, and compress images.
Setup#
Install
npm install multer @aws-sdk/client-s3 npm install sharp # optional, for image compressionPoint it at a bucket
Any S3-compatible storage works. Load these before handling requests — the library reads
process.envbut never loads.envitself.S3_ENDPOINT=https://s3.us-east-1.amazonaws.com S3_SPACES_KEY=your-access-key S3_SPACES_SECRET=your-secret-key S3_BUCKET_NAME=your-bucket S3_REGION=us-east-1 # optional, default us-east-1Per-provider values (AWS, R2, DigitalOcean, MinIO): Environment variables → S3.
Pick where files arrive
On a CRUD router, add
upload(createRouter → S3 uploads). On any other route, usefileUploadMiddleware.
Files on a CRUD router#
For CRUD with files, pass upload to createRouter. The full option list, a complete example and how clients send files are on createRouter → S3 uploads.
app.use('/api/products', createRouter({
model: Product,
upload: { fields: [{ name: 'cover', maxCount: 1 }] },
allowedFields: ['name', 'cover'], // file fields must be writable
}));
Files on your own routes#
For an avatar endpoint, a file inbox, or anything that isn't CRUD, put fileUploadMiddleware in front of your handler:
import { fileUploadMiddleware } from 'express-controller-sets';
const avatarUpload = (req, res, next) =>
fileUploadMiddleware(req, res, next, {
uploadPath: 'avatars/', // note: uploadPath here, path on a router
fields: [{ name: 'avatar', maxCount: 1 }],
imgOptimizations: 'medium',
acl: 'public-read',
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
maxFileSize: 2 * 1024 * 1024,
});
app.post('/api/me/avatar', auth.requireAuth, avatarUpload, async (req, res) => {
await User.updateOne({ _id: req.auth.userId }, { avatar: req.body.avatar });
res.json({ success: true, data: { avatar: req.body.avatar } });
});
| After it runs | Contains |
|---|---|
req.body[field] | The file URL — or an array, or { url }, by the same rules as the router. |
req.file / req.files | Multer's file objects, plus key (the S3 key) and location (the URL). |
| other form fields | On req.body, as strings. |
It takes the same options as a router's upload, except the folder is uploadPath instead of path.
What happens to a file#
Buffered in memory
No temp files.
maxFileSizeandmaxFilesapply here.Identified from its bytes
The file's signature is read; the
Content-Typethe client sent is ignored. Unrecognised files are a 400.Checked against
allowedMimeTypesRenaming
payload.htmltophoto.pngchanges nothing.Optionally compressed
JPEG, PNG and WebP, if
imgOptimizationsis set.Uploaded with a safe key
<path>/<timestamp>-<random>.<ext>, with the extension andContent-Typederived from the final bytes, your ACL, andContent-Disposition: attachmentfor anything not safe to render inline.URL placed in
req.bodyThe handler then saves it like any other field.
| Type | Allowed by default | Served inline | Notes |
|---|---|---|---|
| JPEG, PNG, WebP | ✔ | ✔ | The only formats that can be compressed. |
| GIF, AVIF | ✔ | ✔ | Stored untouched. |
| ✔ | ✘ | Downloaded rather than rendered. | |
| HTML, SVG, XML, JavaScript | ✘ | ✘ | Active content: always attachment, even if you allow it. |
| MP4, HEIC, ZIP, MP3 | ✘ | ✘ | Recognised; allow them explicitly. |
| Anything unrecognised | ✘ | ✘ | 400. |
Why the client's Content-Type is never trusted. An HTML file labelled image/png, stored with that label and a public ACL, renders as a page on your bucket's origin — stored cross-site scripting through your own upload form. Byte sniffing, forced attachment for active content and a private default ACL each close part of that.
Image compression#
Set a level and the image is re-encoded toward a target size, binary-searching quality (25–95, at most seven attempts). You get predictable output sizes instead of one fixed quality.
| Level | Target | Good for |
|---|---|---|
'low' | ~75–80% of the original | Photography, product shots. |
'medium' / 'med' | ~60–65% | Avatars and content images — the usual choice. |
'high' | Under 1 MB: ~40–45%. Over 1 MB: from ~400–450 KB at 1 MB up to ~600–700 KB at 5 MB. | Phone camera uploads. |
- JPEG, PNG and WebP are re-encoded in the same format. GIF, AVIF and PDF are uploaded as received.
- If
sharpis missing or fails, a warning is logged and the original is uploaded. - To compress a buffer yourself:
await compressImage(buffer, 'image/jpeg', 'medium').
Keep allowClientImageOptions off on public routes. A level drives up to seven re-encodes of a multi-megabyte buffer; letting clients choose high turns an upload endpoint into CPU amplification. If enabled, the level is read from ?imgOptimizations=, the body, or the x-img-optimizations header, and is still allowlisted.
When it fails#
| Failure | Response |
|---|---|
| S3 variables missing | 503. The server log names the missing ones. |
| Too large, too many files, unexpected field name | 400 "File upload error: …" |
| File type not allowed or unrecognised | 400 |
| Uploaded file URL returns 403 in a browser | The object is private (the default). Serve it through presigned URLs or a CDN with origin access using file.key, or set acl: 'public-read'. |
S3 configuration is read per request, not at import, so an app that loads its environment after importing the package still works.