Storing and Serving Uploaded Files in Express: A Complete Guide

Storing and Serving Uploaded Files in Express: A Complete Guide
Audience: This post assumes working knowledge of Node.js, Express, and basic middleware concepts. You should be comfortable with npm, routing, and REST API structure.
TL;DR: Use Multer to handle multipart form uploads, store files in a structured local directory, serve them via Express's static middleware, and apply filename sanitization + type validation to avoid security holes.
Problem
When a user uploads a profile photo or a document through your Express API, three questions immediately arise:
Where does the file actually land on disk?
How does a client retrieve it later via a URL?
What stops someone from uploading a malicious
.phpfile or path-traversal payload?
Express doesn't handle file uploads out of the box. And if you wire it up naively — accepting any file, storing it with the original name, serving the raw upload directory — you expose your server to real risks.
This guide walks through the full lifecycle: receiving the upload, storing it safely, and serving it over HTTP.
Solution
We'll use Multer for upload handling and Express's built-in express.static for serving. The folder structure will be organized by resource type, and we'll validate MIME type and sanitize filenames before writing to disk.
Folder Structure
project/
├── uploads/
│ ├── avatars/
│ └── documents/
├── src/
│ ├── middlewares/
│ │ └── upload.js
│ ├── routes/
│ │ └── files.js
│ └── app.js
├── package.json
└── .env
Keeping uploads/ outside src/ prevents accidental bundling and makes it easier to exclude from version control.
Step 1: Install Dependencies
npm init -y
npm install express multer uuid dotenv
multer— handlesmultipart/form-datauuid— generates collision-free filenamesdotenv— keeps base URL configurable
Step 2: Configure Multer with Disk Storage
Multer gives you two storage engines: memoryStorage (file lives in RAM as a Buffer) and diskStorage (file written to disk immediately). For most upload-and-serve workflows, diskStorage is the right choice — it avoids holding large files in memory.
// src/middlewares/upload.js
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const ALLOWED_MIME_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// Route to subfolder based on field name
const folder = file.fieldname === 'avatar' ? 'avatars' : 'documents';
cb(null, path.join(__dirname, '../../uploads', folder));
},
filename: function (req, file, cb) {
const ext = ALLOWED_MIME_TYPES[file.mimetype];
if (!ext) {
return cb(new Error('Unsupported file type'));
}
// UUID prevents filename guessing and path traversal
const safeName = `\({uuidv4()}\){ext}`;
cb(null, safeName);
},
});
const fileFilter = (req, file, cb) => {
if (ALLOWED_MIME_TYPES[file.mimetype]) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${file.mimetype}`), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB hard cap
},
});
module.exports = upload;
Why UUID for filenames? Using the original filename (req.file.originalname) is dangerous. A user can upload a file named ../../etc/passwd or shell.php. Replacing the name with a UUID breaks both path traversal and execution-based attacks.
Why check MIME type in fileFilter AND derive the extension from the MIME map? Browsers send the Content-Type for the file part, but that can be spoofed. The fileFilter rejects obvious mismatches at the Multer level. Deriving the extension from your own trusted map (not from originalname) ensures the stored extension always matches the actual content type your app accepts.
Step 3: Create the Upload Route
// src/routes/files.js
const express = require('express');
const router = express.Router();
const upload = require('../middlewares/upload');
require('dotenv').config();
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
// POST /api/upload/avatar
router.post('/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const fileUrl = `\({BASE_URL}/uploads/avatars/\){req.file.filename}`;
return res.status(201).json({
message: 'Avatar uploaded successfully',
filename: req.file.filename,
size: req.file.size,
url: fileUrl,
});
});
// POST /api/upload/document
router.post('/document', upload.single('document'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const fileUrl = `\({BASE_URL}/uploads/documents/\){req.file.filename}`;
return res.status(201).json({
message: 'Document uploaded successfully',
filename: req.file.filename,
size: req.file.size,
url: fileUrl,
});
});
// Multer error handler
router.use((err, req, res, next) => {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: 'File exceeds 5MB limit' });
}
return res.status(400).json({ error: err.message });
});
module.exports = router;
Step 4: Wire Up Static File Serving in Express
This is the key piece. express.static maps a URL prefix to a filesystem directory. Any file inside the served directory becomes accessible via HTTP — no route handler needed.
// src/app.js
const express = require('express');
const path = require('path');
const fileRoutes = require('./routes/files');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
// Serve the uploads directory as static files
// URL: /uploads/avatars/<filename> → disk: uploads/avatars/<filename>
app.use(
'/uploads',
express.static(path.join(__dirname, '../uploads'), {
dotfiles: 'deny', // Block .htaccess, .env etc.
index: false, // Disable directory listing
etag: true, // Enable cache validation
maxAge: '7d', // Cache files for 7 days in browser
})
);
// Upload API routes
app.use('/api/upload', fileRoutes);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = app;
Static file serving flow:
Client GET /uploads/avatars/3f2a...uuid.jpg
│
▼
express.static middleware
│
▼
Maps to: uploads/avatars/3f2a...uuid.jpg on disk
│
▼
Streams file with correct Content-Type header
Express infers the Content-Type header from the file extension (.jpg → image/jpeg, .pdf → application/pdf). Because we control the extension via our MIME map, this is safe.
Step 5: Test the Upload and Retrieval
# Upload an avatar
curl -X POST http://localhost:3000/api/upload/avatar \
-F "avatar=@/path/to/photo.jpg"
Expected response:
{
"message": "Avatar uploaded successfully",
"filename": "3f2a1c9b-84d2-4e5a-b9f3-12a456789abc.jpg",
"size": 204800,
"url": "http://localhost:3000/uploads/avatars/3f2a1c9b-84d2-4e5a-b9f3-12a456789abc.jpg"
}
# Retrieve the file directly
curl http://localhost:3000/uploads/avatars/3f2a1c9b-84d2-4e5a-b9f3-12a456789abc.jpg --output result.jpg
Step 6: Local Storage vs. External Storage
Local disk works fine for single-server deployments or development. It breaks down when you scale horizontally — two app instances don't share a local filesystem.
| Concern | Local Storage | External (S3, GCS, R2) |
|---|---|---|
| Setup complexity | Low | Medium |
| Horizontal scaling | Fails | Works |
| CDN integration | Manual | Native |
| Backup | Manual | Automatic (with policy) |
| Cost at low volume | Free | Near-zero |
| Latency | Sub-ms (same server) | 10–100ms (network) |
When your app moves to multiple instances (Kubernetes, ECS, etc.), swap Multer's diskStorage for a streaming upload directly to S3 using multer-s3. The route code stays almost identical — only the storage engine changes.
Security Considerations Summary
| Risk | Mitigation Applied |
|---|---|
| Path traversal via filename | UUID replaces original filename |
| Malicious file type | MIME whitelist in fileFilter |
| Extension spoofing | Extension derived from MIME map, not originalname |
| Large file DoS | limits.fileSize: 5MB |
| Directory listing | index: false in static options |
| Hidden file exposure | dotfiles: 'deny' in static options |
| Direct script execution | Files not served from a web-executable path |
One more consideration: if your uploads contain sensitive user data (medical records, private documents), don't serve them via express.static at all. Instead, stream them through an authenticated route that verifies the requesting user owns that file before sending it.
Results
With this setup:
Uploads land in typed subfolders (
avatars/,documents/) with UUID namesFiles are accessible at predictable, shareable URLs immediately after upload
Invalid file types return a
400before anything touches diskOversized files return a
413before anything touches diskDirectory browsing and dotfile access are blocked at the static middleware level
Trade-offs
Local storage limitations:
Not viable for multi-instance deployments without a shared network volume (NFS, EFS)
You own backup and redundancy
Disk fills up silently unless you add monitoring
UUID filenames:
- You lose the original filename. Store it in your database alongside the UUID name if you need to display or restore it.
MIME type checking:
- MIME type from the browser can be spoofed. For high-security scenarios, use a library like
file-typeto inspect the actual file magic bytes after upload instead of trusting theContent-Typeheader.
express.static for uploads:
- No authentication. Any URL that leaks becomes publicly accessible. For private files, use a signed-URL strategy or a controller-gated stream route.
Conclusion
Handling file uploads correctly in Express comes down to three things: store files with safe, unpredictable names; validate type before writing to disk; and serve files through a static middleware configured to block directory listing and dotfile access. Local storage gets you to production on a single server — when you scale out, swap only the Multer storage engine for S3 or equivalent without touching your routes.
Next step: add the file-type package to validate magic bytes post-upload, and wire up a database column to map UUID filenames back to original names for display.
Further Reading
Multer official documentation — storage engines, field options, error handling
Express static middleware options — full list of configuration flags
OWASP File Upload Cheat Sheet — comprehensive security guidance
multer-s3 — drop-in S3 storage engine for Multer
file-type npm package — magic byte detection for uploaded files




