Storing Uploaded Files and Serving Them in Express.js
Hey folks! If you're building any real-world Node.js app—whether it's a social platform, marketplace, blog with image uploads, or SaaS—you'll eventually need to handle file uploads. Today we'll demystify where files actually live, how to serve them efficiently with Express, and how to do it without shooting yourself in the foot.
I’ve shipped features which can handle millions of uploads. Let’s make this intuitive.
1. Where Do Uploaded Files Actually Get Stored?
When a user uploads a file (image, PDF, video, etc.), your server receives it as a stream or buffer. You decide where to persist it.
Common options:
-
Local filesystem (
./uploads/folder on your server) - Cloud object storage (AWS S3, Google Cloud Storage, Cloudflare R2, Azure Blob)
- Database (rare for large files — not recommended except for tiny blobs)
For learning and small/medium apps → local storage is perfect. For production at scale → external storage wins.
2. Local Storage vs External Storage (Mental Model)
Local Storage (Development + Small Apps)
- Pros: Simple, fast, no extra cost, easy debugging
- Cons: Doesn't scale horizontally (multiple servers = sync nightmare), server disk fills up, backups are manual, not CDN-friendly
External/Cloud Storage
- Pros: Infinite scalability, built-in CDN, high durability, pay-as-you-go, works beautifully with multiple app instances
- Cons: Slight latency, costs money at scale, more complex setup initially
My rule of thumb (battle-tested):
Start with local + Multer. When you need to scale or go production, switch the storage engine (you can abstract it nicely).
3. Setting Up Folder-Based Storage Structure
Here's a clean, scalable folder structure I recommend:
project-root/
├── uploads/ # .gitignore this!
│ ├── images/
│ ├── documents/
│ ├── avatars/
│ └── temp/ # for processing
├── public/
│ └── assets/ # (optional - built assets)
├── src/
│ ├── controllers/
│ ├── routes/
│ ├── middleware/
│ └── utils/storage.js # abstraction layer
├── package.json
└── .gitignore
Pro tip: Never store uploads in the root or public directly during upload. Process first, then move.
4. Serving Static Files in Express (The Magic)
Express has a built-in middleware for this: express.static().
// app.js or server.js
import express from 'express';
const app = express();
// Serve static files
app.use('/uploads', express.static('uploads')); // Basic
// or with options
app.use('/uploads', express.static('uploads', {
maxAge: '1d', // Browser caching
etag: true,
lastModified: true
}));
Now any file at uploads/images/cat.jpg becomes accessible at:
https://yoursite.com/uploads/images/cat.jpg
Brain-friendly flow diagram (text version):
Client Upload Request
↓ (multipart/form-data)
Express + Multer Middleware
↓ (validate + save to disk)
uploads/images/abc123.jpg
↓
express.static middleware
↓
Client accesses via URL → served directly by Express
5. Complete Upload Example with Multer (pnpm style)
I love pnpm — it's fast, disk-efficient, and has strict dependency handling. Here's how I set it up:
pnpm add multer
pnpm add -D @types/multer # if using TypeScript
// middleware/upload.js
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(__dirname, '../uploads/images');
// ensure directory exists (use fs-extra in prod)
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
// File filter - security first!
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp|pdf/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (extname && mimetype) {
return cb(null, true);
}
cb(new Error('Only images and PDFs are allowed!'));
};
export const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter
});
Route usage:
app.post('/api/upload', upload.single('image'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const fileUrl = `${req.protocol}://${req.get('host')}/uploads/images/${req.file.filename}`;
res.json({
message: 'Upload successful',
url: fileUrl,
filename: req.file.filename
});
});
6. Accessing Uploaded Files via URL
Once served via express.static, files are publicly accessible. You can:
- Return full URLs from your APIs
- Use them directly in
<img src="..."> - Serve with CDN later by changing the base URL
Advanced tip: Create a utility that generates URLs:
const getFileUrl = (filename) => {
return process.env.NODE_ENV === 'production'
? `https://cdn.yoursite.com/${filename}`
: `${process.env.BASE_URL}/uploads/${filename}`;
};
7. Security Considerations (Don't Skip This!)
This is where most juniors get hacked:
- Always validate file types (both extension + mime)
- Set file size limits
-
Rename files (never trust
originalname— can contain malicious paths) - Scan for malware (ClamAV or cloud service) in production
- Don't allow executable files (.exe, .php, .js, etc.)
- Use helmet.js + CSP headers
- Rate limit uploads
- Store outside of your main app directory if possible
- Set proper file permissions (not 777!)
pnpm add helmet express-rate-limit
Bonus: Production-Ready Abstraction
Create src/utils/storage.js early so you can swap between local, S3, R2 later with minimal changes.
Summary (The Takeaway)
- Start simple with local
uploads/folder +express.static - Use Multer for robust handling
- Always think about the URL your files will live at
- Security is not optional
- Design for future migration to cloud storage
Recommended pnpm stack for uploads:
pnpm add multer express helmet express-rate-limit
pnpm add -D nodemon
Would you like the TypeScript version, S3 integration guide, or Multer with multiple files + progress tracking next? Drop your thoughts below.
Happy coding! 🚀
Top comments (0)