Episode 3 — NodeJS MongoDB Backend Architecture / 3.7 — Handling Files with Express
3.7.e — Working with Express Static
In one sentence: Once files are uploaded and stored on disk, you need a strategy to serve them back to clients —
express.staticis the simplest approach for trusted files, but production systems also need file deletion, URL building, and cleanup routines for orphaned uploads.
Table of Contents
- 1. Serving Uploaded Files with express.static
- 2. Virtual Path Prefix for Uploads
- 3. Building File URLs for API Responses
- 4. Deleting Files — fs.unlink
- 5. File Management — List, Rename, Move
- 6. Cleanup Strategies for Orphaned Uploads
- 7. Complete File Management API
- 8. Key Takeaways
1. Serving Uploaded Files with express.static
express.static is a built-in middleware that serves files from a directory. When a request matches a file in the directory, Express sends it directly without hitting your route handlers.
const express = require('express');
const path = require('path');
const app = express();
// Serve everything in the 'uploads' folder
// Request: GET /image.jpg → Sends uploads/image.jpg
app.use(express.static('uploads'));
app.listen(3000);
How express.static resolves files
Request: GET /photos/cat.jpg
express.static('uploads') checks:
→ Does uploads/photos/cat.jpg exist?
YES → Send it with correct Content-Type header
NO → Call next() — let the next middleware/route handle it
Options you can pass
app.use(express.static('uploads', {
// Set browser cache duration
maxAge: '1d', // Cache for 1 day (string format)
// maxAge: 86400000, // Same in milliseconds
// Prevent directory listings
index: false, // Don't serve index.html from directories
// Set custom headers
setHeaders: (res, filepath) => {
// Force download for non-image files
if (!filepath.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
res.setHeader('Content-Disposition', 'attachment');
}
// Security header
res.setHeader('X-Content-Type-Options', 'nosniff');
},
// Don't send hidden files (starting with .)
dotfiles: 'deny',
// Enable ETag for cache validation
etag: true,
// Enable Last-Modified header
lastModified: true
}));
express.static options reference
| Option | Default | Description |
|---|---|---|
dotfiles | 'ignore' | How to handle dotfiles: 'allow', 'deny', 'ignore' |
etag | true | Enable/disable ETag generation |
extensions | false | Try file extensions (e.g., ['html', 'htm']) |
index | 'index.html' | Directory index file, or false |
lastModified | true | Send Last-Modified header |
maxAge | 0 | Cache-Control max-age (ms or string) |
redirect | true | Redirect trailing / to directory |
setHeaders | — | Function to set custom headers |
2. Virtual Path Prefix for Uploads
A virtual path prefix creates a URL namespace that does not exist as a folder. This is the recommended pattern:
// Without prefix — files served at root URL
app.use(express.static('uploads'));
// GET /avatar.jpg → uploads/avatar.jpg
// WITH prefix — files served under /uploads URL
app.use('/uploads', express.static('uploads'));
// GET /uploads/avatar.jpg → uploads/avatar.jpg
// WITH a DIFFERENT prefix — decouple URL from folder name
app.use('/files', express.static('uploads'));
// GET /files/avatar.jpg → uploads/avatar.jpg
// Using absolute path (recommended for production)
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
Why use a virtual prefix?
┌─────────────────────────────────────────────────────────┐
│ WITHOUT PREFIX │
│ │
│ app.use(express.static('uploads')); │
│ │
│ Problem: GET /api/users might match a file named │
│ "api/users" in uploads/ → confusing conflicts │
│ │
│ WITH PREFIX │
│ │
│ app.use('/uploads', express.static('uploads')); │
│ │
│ Clean: /uploads/* = files, /api/* = routes │
│ No conflicts, clear separation │
└─────────────────────────────────────────────────────────┘
Multiple static directories
// Serve public assets (CSS, JS, images)
app.use(express.static('public'));
// Serve user uploads under /uploads prefix
app.use('/uploads', express.static('uploads'));
// Serve processed images under /media prefix
app.use('/media', express.static('processed'));
3. Building File URLs for API Responses
When a file is uploaded, your API should return a complete URL that the frontend can use to display or download the file.
Building URLs manually
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, Date.now() + ext);
}
});
const upload = multer({ storage });
// Serve uploads
app.use('/uploads', express.static('uploads'));
app.post('/api/upload', upload.single('avatar'), (req, res) => {
// Build the URL
const fileUrl = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`;
res.json({
message: 'Upload successful',
file: {
originalName: req.file.originalname,
url: fileUrl, // http://localhost:3000/uploads/1699123456789.jpg
size: req.file.size
}
});
});
Helper function for URL building
function buildFileUrl(req, filename) {
// Works in both development and production
const protocol = req.protocol; // 'http' or 'https'
const host = req.get('host'); // 'localhost:3000' or 'api.example.com'
return `${protocol}://${host}/uploads/${filename}`;
}
// Usage in route
app.post('/api/upload', upload.single('photo'), (req, res) => {
const url = buildFileUrl(req, req.file.filename);
res.json({ url });
});
Using environment variables for production
// In production, the URL might go through a CDN or different domain
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
function buildFileUrl(filename) {
return `${BASE_URL}/uploads/${filename}`;
}
// .env file
// BASE_URL=https://cdn.example.com
Storing URLs in the database (with MongoDB/Mongoose)
const UserSchema = new mongoose.Schema({
name: String,
email: String,
avatar: {
url: String, // Full URL: https://example.com/uploads/abc123.jpg
filename: String, // Filename on disk: abc123.jpg
originalName: String, // Original name: my-photo.jpg
mimetype: String, // image/jpeg
size: Number // bytes
}
});
app.post('/api/users/:id/avatar', upload.single('avatar'), async (req, res) => {
const user = await User.findByIdAndUpdate(req.params.id, {
avatar: {
url: buildFileUrl(req, req.file.filename),
filename: req.file.filename,
originalName: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size
}
}, { new: true });
res.json(user);
});
4. Deleting Files — fs.unlink
When a user deletes their profile picture or you need to clean up old files:
Async deletion (recommended)
const fs = require('fs').promises;
const path = require('path');
app.delete('/api/files/:filename', async (req, res) => {
try {
const filename = path.basename(req.params.filename); // prevent traversal
const filepath = path.join(__dirname, 'uploads', filename);
// Check if file exists before deleting
await fs.access(filepath);
// Delete the file
await fs.unlink(filepath);
res.json({ message: `File ${filename} deleted successfully` });
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
res.status(500).json({ error: 'Failed to delete file' });
}
});
Deleting when updating (replace old file)
app.put('/api/users/:id/avatar', upload.single('avatar'), async (req, res) => {
try {
const user = await User.findById(req.params.id);
// Delete the OLD avatar file if it exists
if (user.avatar && user.avatar.filename) {
const oldPath = path.join(__dirname, 'uploads', user.avatar.filename);
try {
await fs.unlink(oldPath);
console.log('Old avatar deleted:', user.avatar.filename);
} catch (err) {
// File might already be deleted — log but don't fail
console.warn('Could not delete old avatar:', err.message);
}
}
// Save the NEW avatar info
user.avatar = {
url: buildFileUrl(req, req.file.filename),
filename: req.file.filename,
originalName: req.file.originalname,
mimetype: req.file.mimetype,
size: req.file.size
};
await user.save();
res.json(user);
} catch (error) {
res.status(500).json({ error: 'Failed to update avatar' });
}
});
Sync deletion (for scripts, not routes)
const fs = require('fs');
// Only use sync in scripts, NEVER in route handlers
try {
fs.unlinkSync('uploads/old-file.jpg');
} catch (err) {
console.error('Delete failed:', err.message);
}
5. File Management — List, Rename, Move
Listing files in a directory
const fs = require('fs').promises;
app.get('/api/files', async (req, res) => {
try {
const files = await fs.readdir(path.join(__dirname, 'uploads'));
// Get file details
const fileDetails = await Promise.all(
files.map(async (filename) => {
const filepath = path.join(__dirname, 'uploads', filename);
const stats = await fs.stat(filepath);
return {
name: filename,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
url: `${req.protocol}://${req.get('host')}/uploads/${filename}`
};
})
);
res.json({ count: fileDetails.length, files: fileDetails });
} catch (error) {
res.status(500).json({ error: 'Failed to list files' });
}
});
Renaming a file
app.patch('/api/files/:filename/rename', async (req, res) => {
try {
const oldName = path.basename(req.params.filename);
const newName = path.basename(req.body.newName);
const oldPath = path.join(__dirname, 'uploads', oldName);
const newPath = path.join(__dirname, 'uploads', newName);
await fs.rename(oldPath, newPath);
res.json({ message: 'File renamed', oldName, newName });
} catch (error) {
res.status(500).json({ error: 'Failed to rename file' });
}
});
Moving a file to a different directory
async function moveFile(sourcePath, destDir) {
const filename = path.basename(sourcePath);
const destPath = path.join(destDir, filename);
// Ensure destination directory exists
await fs.mkdir(destDir, { recursive: true });
// rename() works across directories on the same filesystem
// For cross-filesystem moves, use copyFile + unlink
try {
await fs.rename(sourcePath, destPath);
} catch (err) {
if (err.code === 'EXDEV') {
// Cross-device move — copy then delete
await fs.copyFile(sourcePath, destPath);
await fs.unlink(sourcePath);
} else {
throw err;
}
}
return destPath;
}
6. Cleanup Strategies for Orphaned Uploads
Orphaned uploads are files on disk that are not referenced by any database record. They waste storage and should be cleaned up regularly.
How orphans happen
┌──────────────────────────────────────────────────────────┐
│ HOW ORPHANED FILES OCCUR │
│ │
│ 1. User uploads file → Multer saves to disk │
│ 2. Route handler crashes BEFORE saving URL to database │
│ → File on disk, no reference in DB = ORPHAN │
│ │
│ 3. User deletes their account → DB record removed │
│ → Code forgets to delete the avatar file = ORPHAN │
│ │
│ 4. User updates avatar → new file saved │
│ → Code forgets to delete old file = ORPHAN │
└──────────────────────────────────────────────────────────┘
Strategy 1: Scheduled cleanup script
const fs = require('fs').promises;
const path = require('path');
const mongoose = require('mongoose');
async function cleanupOrphans() {
const uploadsDir = path.join(__dirname, 'uploads');
// 1. Get all filenames on disk
const filesOnDisk = await fs.readdir(uploadsDir);
// 2. Get all filenames referenced in the database
const users = await User.find({}, 'avatar.filename');
const posts = await Post.find({}, 'images.filename');
const referencedFiles = new Set();
users.forEach(u => {
if (u.avatar?.filename) referencedFiles.add(u.avatar.filename);
});
posts.forEach(p => {
p.images.forEach(img => {
if (img.filename) referencedFiles.add(img.filename);
});
});
// 3. Find orphans (on disk but not in DB)
const orphans = filesOnDisk.filter(f => !referencedFiles.has(f));
// 4. Delete orphans
let deleted = 0;
for (const orphan of orphans) {
try {
await fs.unlink(path.join(uploadsDir, orphan));
deleted++;
console.log(`Deleted orphan: ${orphan}`);
} catch (err) {
console.error(`Failed to delete ${orphan}:`, err.message);
}
}
console.log(`Cleanup complete: ${deleted}/${orphans.length} orphans deleted`);
}
// Run daily with node-cron
// npm install node-cron
const cron = require('node-cron');
cron.schedule('0 3 * * *', cleanupOrphans); // Run at 3 AM daily
Strategy 2: Age-based cleanup
async function cleanupOldFiles(maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
const uploadsDir = path.join(__dirname, 'uploads');
const files = await fs.readdir(uploadsDir);
const now = Date.now();
for (const file of files) {
const filepath = path.join(uploadsDir, file);
const stats = await fs.stat(filepath);
const age = now - stats.mtimeMs;
if (age > maxAgeMs) {
await fs.unlink(filepath);
console.log(`Deleted old file: ${file} (age: ${Math.round(age / 86400000)} days)`);
}
}
}
Strategy 3: Temp directory pattern
// Upload to temp first, then move to permanent storage after DB save succeeds
app.post('/api/upload', upload.single('photo'), async (req, res) => {
const tempPath = req.file.path; // uploads/temp/abc123.jpg
try {
// Save to database first
const post = await Post.create({
title: req.body.title,
image: req.file.filename
});
// Move from temp to permanent storage
const permanentPath = path.join('uploads', 'permanent', req.file.filename);
await fs.rename(tempPath, permanentPath);
res.json(post);
} catch (error) {
// DB save failed — delete the temp file
try { await fs.unlink(tempPath); } catch (e) { /* ignore */ }
res.status(500).json({ error: 'Upload failed' });
}
});
7. Complete File Management API
const express = require('express');
const multer = require('multer');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const UPLOADS_DIR = path.join(__dirname, 'uploads');
// --- Storage config ---
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
await fs.mkdir(UPLOADS_DIR, { recursive: true }).catch(() => {});
cb(null, UPLOADS_DIR);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, crypto.randomBytes(16).toString('hex') + ext);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only images allowed'), false);
}
});
// --- Serve files ---
app.use('/uploads', express.static(UPLOADS_DIR, {
maxAge: '7d',
setHeaders: (res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
}
}));
// --- Upload ---
app.post('/api/files', upload.single('file'), (req, res) => {
const url = `${req.protocol}://${req.get('host')}/uploads/${req.file.filename}`;
res.status(201).json({
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype,
url
});
});
// --- List all files ---
app.get('/api/files', async (req, res) => {
const files = await fs.readdir(UPLOADS_DIR);
const details = await Promise.all(files.map(async (name) => {
const stats = await fs.stat(path.join(UPLOADS_DIR, name));
return {
name,
size: stats.size,
url: `${req.protocol}://${req.get('host')}/uploads/${name}`,
uploaded: stats.birthtime
};
}));
res.json({ count: details.length, files: details });
});
// --- Get single file info ---
app.get('/api/files/:filename', async (req, res) => {
try {
const name = path.basename(req.params.filename);
const stats = await fs.stat(path.join(UPLOADS_DIR, name));
res.json({
name,
size: stats.size,
url: `${req.protocol}://${req.get('host')}/uploads/${name}`,
uploaded: stats.birthtime
});
} catch {
res.status(404).json({ error: 'File not found' });
}
});
// --- Delete ---
app.delete('/api/files/:filename', async (req, res) => {
try {
const name = path.basename(req.params.filename);
await fs.unlink(path.join(UPLOADS_DIR, name));
res.json({ message: `${name} deleted` });
} catch (err) {
if (err.code === 'ENOENT') return res.status(404).json({ error: 'File not found' });
res.status(500).json({ error: 'Delete failed' });
}
});
// --- Error handler ---
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: err.message, code: err.code });
}
if (err) return res.status(400).json({ error: err.message });
next();
});
app.listen(3000, () => console.log('File API running on port 3000'));
8. Key Takeaways
express.staticserves files directly from a directory — no route handler needed.- Use a virtual path prefix like
/uploadsto avoid conflicts with API routes. - Build complete URLs using
req.protocol,req.get('host'), and the filename — store URLs in the database. - Delete files with
fs.promises.unlink()— always handle the "file not found" case gracefully. - Orphaned files happen when uploads succeed but DB saves fail — use temp directories or scheduled cleanup.
- Use
path.basename()on user-provided filenames to prevent directory traversal attacks. - Set security headers (
X-Content-Type-Options: nosniff,Content-Disposition: attachment) when serving uploads. - For production, prefer cloud storage over local disk — but
express.staticis fine for development and small apps.
Explain-It Challenge
Can you explain to a friend: "How do you serve uploaded files back to the frontend, and what are the three things you should worry about (URL building, deletion, and orphan cleanup)?" Walk through a complete flow in under 2 minutes.