Skip to main content

Command Palette

Search for a command to run...

Storing and Serving Uploaded Files in Express: A Complete Guide

Updated
9 min read
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:

  1. Where does the file actually land on disk?

  2. How does a client retrieve it later via a URL?

  3. What stops someone from uploading a malicious .php file 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 — handles multipart/form-data

  • uuid — generates collision-free filenames

  • dotenv — 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 (.jpgimage/jpeg, .pdfapplication/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 names

  • Files are accessible at predictable, shareable URLs immediately after upload

  • Invalid file types return a 400 before anything touches disk

  • Oversized files return a 413 before anything touches disk

  • Directory 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-type to inspect the actual file magic bytes after upload instead of trusting the Content-Type header.

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

More from this blog

ThitaInfo Blogs

63 posts

Making AI simple, fun, and practical for developers.