Not every payload is JSON. Profile pictures, invoices, CSV imports and generated reports all travel through your API as binary data. In this lesson you will learn the two ways to accept uploads, how to validate and store files safely with multer, when to let clients upload directly to cloud storage, and how to serve downloads with the right headers.
| Approach | Request | Best for |
|---|---|---|
| multipart/form-data | Body with named parts, each with a filename and type | HTML forms, several files plus fields in one request |
| Raw binary body | PUT /files/report.pdf with Content-Type: application/pdf | Single files from scripts, direct-to-storage uploads |
Multipart is the browser default. Testing it from the terminal is one flag:
curl -X POST https://api.example.com/users/42/avatar \
-H "Authorization: Bearer $TOKEN" \
-F "avatar=@./photo.png"express.json() ignores multipart bodies. The multer middleware parses them and hands you the file:
const multer = require("multer");
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ["image/png", "image/jpeg"].includes(file.mimetype)),
});
app.post("/users/:id/avatar", upload.single("avatar"), async (req, res) => {
if (!req.file) return res.status(415).json({ error: "Only PNG and JPEG images are accepted" });
const key = `avatars/${req.params.id}/${crypto.randomUUID()}.${req.file.mimetype.split("/")[1]}`;
await storage.put(key, req.file.buffer, req.file.mimetype); // S3, GCS, local disk...
res.status(201).set("Location", `/files/${key}`).json({ url: `/files/${key}`, size: req.file.size });
});
app.use((err, req, res, next) => {
if (err.code === "LIMIT_FILE_SIZE") return res.status(413).json({ error: "File exceeds 5 MB" });
next(err);
});Three safety rules are built in. The file gets a server-generated name; the client's originalname is never used as a path, which prevents directory traversal. The client-supplied mimetype can lie, so also check the magic bytes with a library such as file-type. And oversized uploads fail with 413 Content Too Large before they fill memory. For large files use multer.diskStorage or stream to object storage.
Routing gigabytes through your API server wastes its capacity. The scalable pattern is a presigned URL: the API returns a short-lived URL signed for a specific key, size and content type, and the client PUTs the bytes directly to S3 or a similar service:
POST /uploads with { "filename": "video.mp4", "contentType": "video/mp4", "size": 734003200 }{ "uploadUrl": "https://bucket.example.com/...signature", "key": "videos/...", "expiresIn": 900 }PUT uploadUrl with the raw bodyPOST /videos with { "key": "videos/..." } to create the resourceYour server never touches the bytes, yet it still decides who may upload what.
For a file on disk, res.download() streams it with Content-Disposition: attachment so the browser saves it. Both res.download() and res.sendFile() honour Range requests with 206 Partial Content, so downloads can resume. For object storage, redirect to a short-lived signed URL rather than proxying the bytes:
app.get("/reports/:id/pdf", async (req, res) => {
const report = await db.reports.findById(req.params.id);
if (!report || report.ownerId !== req.auth.sub) return res.status(404).end();
if (report.storage === "local") {
return res.download(report.path, `report-${report.id}.pdf`);
}
const signedUrl = await storage.signedGetUrl(report.key, { expiresIn: 300 });
res.redirect(302, signedUrl);
});Use Content-Disposition: inline when the browser should render the file instead.
Content-Disposition; an uploaded HTML file then runs as your site.Which status code should an API return when an uploaded file exceeds the allowed size?
multipart/form-data (forms, several parts) or as a raw binary body (single file).multer parses multipart requests; enforce limits.fileSize, filter types and answer 413 or 415.res.download() or a signed redirect, after checking authorization.Next lesson: Asynchronous Operations: Long-Running Jobs, Polling and Webhooks — handle work that takes longer than a single request.