Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
18 minLesson 32 of 40
Backend with Node.js

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

Get Notes Free →
!