File Uploads & Middleware
File Uploads with Node.js
Handling file uploads is a requirement for nearly every production application — profile photos, course cover images, document attachments, CSV imports. This lesson covers the complete workflow: receiving files from the browser, validating them, storing them securely, and serving them back.
How File Uploads Work
When a browser sends a file, it uses multipart/form-data encoding. The HTTP request contains multiple "parts" — one per field or file. Your server needs to parse this format.
<!-- The enctype is required for file uploads -->
<form action="/api/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="avatar" accept="image/*"/>
<button type="submit">Upload</button>
</form>
Multer — File Upload Middleware
Multer is the standard library for handling multipart/form-data in Express:
npm install multer
npm install -D @types/multer
Memory Storage (for processing before saving)
import multer from "multer";
// Store file in memory as Buffer
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max
files: 1, // 1 file at a time
},
fileFilter(req, file, callback) {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.mimetype)) {
return callback(new Error("Only JPEG, PNG, and WebP images are allowed"));
}
callback(null, true);
},
});
// Single file upload
app.post("/api/avatar", upload.single("avatar"), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
// req.file.buffer is the file contents
// req.file.originalname, mimetype, size
console.log(`Received: ${req.file.originalname} (${req.file.size} bytes)`);
res.json({ message: "File received", size: req.file.size });
});
// Multiple files
app.post("/api/gallery", upload.array("images", 10), (req, res) => {
const files = req.files as Express.Multer.File[];
res.json({ count: files.length });
});
Disk Storage (write directly to disk)
import path from "path";
import { v4 as uuidv4 } from "uuid";
const diskStorage = multer.diskStorage({
destination(req, file, callback) {
callback(null, "public/uploads/");
},
filename(req, file, callback) {
// Use UUID to prevent filename collisions and path traversal
const ext = path.extname(file.originalname).toLowerCase();
callback(null, `${uuidv4()}${ext}`);
},
});
const diskUpload = multer({ storage: diskStorage, limits: { fileSize: 10 * 1024 * 1024 } });
app.post("/api/upload", diskUpload.single("file"), (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file" });
const url = `/uploads/${req.file.filename}`;
res.status(201).json({ url });
});
Then serve the uploads folder as static files:
import express from "express";
app.use("/uploads", express.static("public/uploads"));
Image Processing with Sharp
For profile photos and course cover images, you almost always want to resize and optimize before storing:
npm install sharp
import sharp from "sharp";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import fs from "fs/promises";
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
app.post("/api/avatar", upload.single("avatar"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file" });
const filename = `${uuidv4()}.webp`;
const outputPath = path.join("public/uploads/avatars", filename);
await sharp(req.file.buffer)
.resize(200, 200, { fit: "cover" }) // Crop to square
.webp({ quality: 85 }) // Convert to WebP
.toFile(outputPath);
res.json({ url: `/uploads/avatars/${filename}` });
});
app.post("/api/course-cover", upload.single("cover"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file" });
const filename = `${uuidv4()}.webp`;
const outputPath = path.join("public/uploads/covers", filename);
await sharp(req.file.buffer)
.resize(1280, 720, { fit: "cover" }) // 16:9 aspect ratio
.webp({ quality: 90 })
.toFile(outputPath);
res.json({ url: `/uploads/covers/${filename}` });
});
Always convert to WebP — it's 25-35% smaller than JPEG at the same quality. Browser support is now universal.
Uploading to Cloud Storage (AWS S3 / Cloudflare R2)
For production, store files in object storage — not on your server's disk. Disk storage doesn't scale, and files disappear when you redeploy.
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { v4 as uuidv4 } from "uuid";
import sharp from "sharp";
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const BUCKET = process.env.S3_BUCKET!;
async function uploadToS3(buffer: Buffer, key: string, contentType: string): Promise<string> {
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}));
return `https://${BUCKET}.s3.amazonaws.com/${key}`;
}
async function deleteFromS3(key: string) {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
// Upload endpoint
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
app.post("/api/avatar", upload.single("avatar"), async (req, res) => {
if (!req.file) return res.status(400).json({ error: "No file" });
const processed = await sharp(req.file.buffer)
.resize(200, 200, { fit: "cover" })
.webp({ quality: 85 })
.toBuffer();
const key = `avatars/${uuidv4()}.webp`;
const url = await uploadToS3(processed, key, "image/webp");
// Save url to database for this user
await db.user.update({ where: { id: req.user.id }, data: { avatarUrl: url } });
res.json({ url });
});
Presigned URLs — Upload Directly from the Browser
For large files, don't route them through your server. Generate a presigned URL — a temporary permission for the browser to upload directly to S3:
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// Step 1: Browser asks your API for a presigned URL
app.post("/api/upload-url", async (req, res) => {
const { filename, contentType } = req.body;
const key = `uploads/${uuidv4()}-${filename}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
res.json({ uploadUrl, key });
});
// Step 2: Browser uploads directly to S3 using the presigned URL
// Step 3: Browser notifies your API with the key when done
app.post("/api/confirm-upload", async (req, res) => {
const { key } = req.body;
const url = `https://${BUCKET}.s3.amazonaws.com/${key}`;
await db.user.update({ where: { id: req.user.id }, data: { avatarUrl: url } });
res.json({ url });
});
On the frontend:
// 1. Get presigned URL
const { uploadUrl, key } = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
headers: { "Content-Type": "application/json" },
}).then(r => r.json());
// 2. Upload directly to S3
await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
// 3. Notify your API
await fetch("/api/confirm-upload", {
method: "POST",
body: JSON.stringify({ key }),
headers: { "Content-Type": "application/json" },
});
Error Handling for Uploads
Multer errors need special handling — they're thrown differently than regular errors:
app.post("/api/upload", (req, res) => {
upload.single("file")(req, res, (err) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
return res.status(400).json({ error: "File too large. Maximum 5MB." });
}
return res.status(400).json({ error: err.message });
}
if (err) {
return res.status(400).json({ error: err.message });
}
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
}
res.json({ url: `/uploads/${req.file.filename}` });
});
});
Next lesson: PostgreSQL fundamentals — relational databases for production applications.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises