What an upload actually looks like
Each part has headers, a blank line, then its content, and the parts are separated by the boundary from the Content-Type header. A JSON body parser cannot read this, which is why uploads always need their own middleware.
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----X99
------X99
Content-Disposition: form-data; name="title"
Front door
------X99
Content-Disposition: form-data; name="photo"; filename="door.png"
Content-Type: image/png
<the bytes>
------X99--
In practice: multer or busboy
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 5 * 1024 * 1024, files: 1 }, // always
fileFilter(req, file, cb) {
cb(null, ["image/png", "image/jpeg"].includes(file.mimetype));
},
});
app.post("/doors/:id/photo", upload.single("photo"), (req, res) => {
req.file; // { originalname, mimetype, size, path }
req.body; // the text fields
res.status(201).json({ ok: true });
});
The rules that keep it safe
- A size limit, always. Without one, a single request can fill the disk or the memory.
- Never trust
originalname."../../etc/cron.d/evil"is a valid filename as far as a client is concerned. Generate your own name and keep the original only as a label. - Never trust the content type. It is whatever the client typed. Check the magic bytes of the file when it matters.
- Never serve an upload from a path that can execute. Store outside the web root, or on object storage, and serve through a handler.
- Strip metadata from images if the file will be public: EXIF carries GPS coordinates.
Accept photo.php, keep the name, store it in a folder the server executes, serve it back — and the upload form has become a shell. Every step of that chain is one of the rules above.
Direct to storage
For anything beyond a small avatar this is the normal architecture: presigned URLs, a short expiry, and limits encoded in the signature.
// 1. the browser asks your API for permission
const { url, fields } = await api.post("/uploads/sign", { type: "image/png", size });
// 2. the browser sends the file straight to S3/R2 with that signed URL
// 3. it tells your API the key, and you record it
// your server stays small, fast and out of the data path
Serving static files
- Fingerprinted asset (
app.7f3a9c.js): cache for a year, immutable. - HTML:
no-cache— check with the server every time, but a 304 is cheap. - Anything user-specific:
private, no-store. - In front of a real deployment, a CDN does this better than your Node process ever will.
app.use(express.static("public", {
maxAge: "1y",
immutable: true, // for /assets/app.7f3a9c.js — the name changes on change
etag: true,
}));
// index.html must NOT be cached for a year, or nobody ever gets the new build
res.setHeader("Cache-Control", "no-cache"); // revalidate every time
And the boring one
Put a reverse proxy (nginx, Caddy, a platform router) in front. It serves static files faster, terminates TLS, enforces body limits before your process sees them, and does not block your event loop.