DEV Community

Bhupesh Chandra Joshi
Bhupesh Chandra Joshi

Posted on

Storing Uploaded Files and Serving Them in Express

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
Enter fullscreen mode Exit fullscreen mode

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
}));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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
});
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

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}`;
};
Enter fullscreen mode Exit fullscreen mode

7. Security Considerations (Don't Skip This!)

This is where most juniors get hacked:

  1. Always validate file types (both extension + mime)
  2. Set file size limits
  3. Rename files (never trust originalname — can contain malicious paths)
  4. Scan for malware (ClamAV or cloud service) in production
  5. Don't allow executable files (.exe, .php, .js, etc.)
  6. Use helmet.js + CSP headers
  7. Rate limit uploads
  8. Store outside of your main app directory if possible
  9. Set proper file permissions (not 777!)
pnpm add helmet express-rate-limit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)