AiTechWorlds
AiTechWorlds
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.
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 is the standard library for handling multipart/form data in Express:
npm install multer
npm install -D @types/multerimport 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 });
});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"));For profile photos and course cover images, you almost always want to resize and optimize before storing:
npm install sharpimport 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.
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-presignerimport { 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 });
});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" },
});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