Storing & Serving Uploaded Files in Express
A practical guide to where files live, how to expose them over HTTP, and the security rules you cannot skip.

Contents
Where uploaded files are stored
Local storage vs. external storage
Serving static files in Express
Accessing uploaded files via URL
Security considerations
01 —Where Uploaded Files Are Stored
When a user uploads a file through your Express app, the server needs to decide where to write that file on disk — and that decision shapes how the rest of your application works. The most common approach for small-to-medium apps is local disk storage, using a library like multer.
The standard convention is to create a dedicated uploads/ directory at the root of your project. Within it, you might add sub-directories to organise files by type, user, or date.
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'uploads/'); // files land here
},
filename: (req, file, cb) => {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, unique + path.extname(file.originalname));
}
});
const upload = multer({ storage });
The destination callback tells multer which directory to write to. The filename callback determines what the file is called on disk — in this case a timestamp + random number + original extension, which avoids name collisions and prevents users from overwriting each other's files.
The uploads/ folder is kept at the project root, separate from your source code. This matters: you should add uploads/ to your .gitignore so user files are never committed to your repository.
02 —Local Storage vs. External Storage
Saving files directly to the server's disk is the simplest starting point, but it has real limits at scale. Here's how the two approaches compare:
| Factor | Local disk storage | External storage (S3, GCS…) |
|---|---|---|
| Setup | Simple — one library, one folder | More config — SDK + credentials |
| Cost | Free (server disk space) | Pay-per-GB, usually cheap |
| Scalability | Tied to one server | Infinite, CDN-ready |
| Reliability | Files lost if server dies | Redundant, durable by default |
| Best for | Dev/prototypes, small apps | Production, multi-server apps |
For production apps that run on multiple servers, local disk storage breaks down immediately — if a file lands on server A, server B can't find it. External object storage (AWS S3, Google Cloud Storage, Cloudflare R2) stores files independently of any server, makes them available from anywhere, and typically plugs into a CDN so files load fast globally.
When using external storage with multer, swap multer.diskStorage for multer-s3 or similar middleware — the rest of your upload route stays the same.
💡Rule of thumb: Start with local disk storage during development and prototyping. Switch to external storage before your first real deployment — retrofitting later is more painful than setting it up early.
03 —Serving Static Files in Express
Once a file is saved to disk, you need a way for clients to download or view it over HTTP. Express's built-in express.static() middleware handles this — it maps a URL path to a local directory and serves files from it automatically.
const express = require('express');
const app = express();
// Serve the uploads/ folder at the /uploads URL path
app.use('/uploads', express.static('uploads'));
app.listen(3000);
With that one line in place, a file saved at uploads/1718000000000-avatar.png on disk becomes accessible at http://localhost:3000/uploads/1718000000000-avatar.png in the browser. Express reads the file and streams it to the client — no additional route handler needed.
The first argument to app.use() is the URL prefix; the argument to express.static() is the directory on disk. These don't have to match, but keeping them the same avoids confusion.
How static file serving flows
⚠️
Note:
express.static()serves everything in the target directory. Never point it directly at your project root or any folder containing sensitive files like.envor your source code.
04 —Accessing Uploaded Files via URL
After a file is uploaded and stored, your API should return its public URL so clients can display or link to it. The pattern looks like this:
router.post('/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file received' });
}
// Build a public URL the client can store and use
const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;
res.json({
message: 'Upload successful',
url: fileUrl,
size: req.file.size,
type: req.file.mimetype
});
});
// Example response:
// {
// "url": "http://localhost:3000/uploads/1718001234567-abc.png",
// "size": 48291,
// "type": "image/png"
// }
The req.file.filename property (set by multer) is the generated name on disk. Prepending the host and the /uploads URL prefix gives you a fully-formed URL that maps directly to the file you've already configured express.static() to serve.
In a database-backed app, you'd typically save this URL to the relevant record so you can retrieve and display it later without knowing the underlying file path.
05 —Security Considerations
File uploads are one of the most exploited attack vectors in web apps. A poorly configured upload endpoint can allow an attacker to upload executable scripts, exhaust your disk space, or access files they shouldn't. Here's what to lock down:
Validate file types
Never trust the extension in the original filename — always inspect the MIME type reported by multer and, ideally, validate the file's actual binary signature (magic bytes). Limit which types are accepted:
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (!ALLOWED_TYPES.includes(file.mimetype)) {
return cb(new Error('File type not allowed'));
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024 // 5 MB maximum
}
});
Security checklist
✓
Rename every file on disk. Never save using the original filename — attackers can craft names like
../../etc/passwdorshell.php. Generate a random name server-side.✓
Set a file-size limit. Without
limits.fileSize, a single upload can exhaust your server's disk. Choose a sensible cap for your use case.✓
Serve uploads from a separate origin. Serving user-uploaded files from a different domain (or CDN) prevents malicious HTML or JS files from accessing your site's cookies.
✓
Set
Content-Disposition: attachmentfor downloads. This forces files to be downloaded rather than rendered by the browser, preventing stored XSS via uploaded HTML files.✓
Rate-limit your upload endpoint. Without rate limiting, an attacker can flood your endpoint and fill your disk. Use
express-rate-limitor a reverse proxy rule.✗
Never expose the raw filesystem path. The URL the client receives should map to a URL prefix, not reveal where files live on disk (e.g.
/var/www/uploads/).
🔒
Critical: If your app allows authenticated-only file access, do not use
express.static()for those files — it serves files to anyone with the URL. Instead, create a protected route that checks authentication before streaming the file withres.sendFile().
Protected file serving example
// Only authenticated users can fetch their own files
router.get('/files/:filename', requireAuth, (req, res) => {
const filename = path.basename(req.params.filename); // strip path traversal
const filePath = path.join(__dirname, '../uploads', filename);
res.sendFile(filePath, (err) => {
if (err) res.status(404).json({ error: 'File not found' });
});
});
Using path.basename() strips any directory components from the filename parameter, blocking path traversal attacks like ../../secret.txt. This is a small but essential line of defence.
→Putting It All Together
To recap the full lifecycle of an uploaded file in Express: multer intercepts the multipart request and writes the file to uploads/ with a server-generated name. Your route handler constructs a URL and returns it to the client. express.static() serves the file when that URL is requested. And a set of validation rules, size limits, and authentication checks keep the whole thing from being weaponised.
Start with this foundation, then layer in external storage when your app outgrows a single server.
Express.js File Handling Guide · Node.js Backend Development



