Skip to main content

Command Palette

Search for a command to run...

Storing Uploaded Files and Serving Them in Express

Understand serving of files using express

Published
โ€ข7 min read
Storing Uploaded Files and Serving Them in Express
S

Frontend Developer ๐Ÿ’ป | Fueled by curiosity and Tea โ˜• | Always learning and exploring new technologies.

As we have seen in previous article how to upload file using express and multer. Uploading a file is just the first half of the story. The other half where it lives and how you retrieve it.

Where Uploaded Files Actually Live

When Multer processes a file, it writes it to a location on your server's filesystem. Exactly where depends on how you configured your storage. With the simple dest option, all files go into one flat folder. With diskStorage, you control every part of the path.

By default, that folder is relative to wherever your node process is started usually the project root. So dest: 'uploads/' means the files end up at your-project/uploads/. They live on disk like any other files, readable by any process with access to that directory.

Use subfolders within uploads/ to separate different file types โ€” avatars, documents, product images. It keeps the directory manageable and makes it easier to apply different policies (expiry, access control) per category.

Local Storage vs. External Storage

When you first build file upload functionality, storing files on the same server your application runs on local disk storage is the natural choice. It's simple, fast, and requires no third-party services. For development and low-traffic applications, it works perfectly well.

At scale, though, local disk has a few fundamental problems: disk space is finite, files don't survive server replacements or container restarts, and if you run multiple instances of your server behind a load balancer, only the instance that received the upload has the file. External storage services like S3, Cloudinary, or Google Cloud Storage exists to solve these problems.

This article focuses on local disk โ€” the right place to start. External storage integrations (AWS S3, etc.) follow the same conceptual model but involve additional SDK configuration. Once you understand local storage, the leap to cloud storage is mostly configuration, not new concepts.

Serving Static Files in Express

Express ships with a built-in middleware called express.static(). When you mount it, Express looks in the specified folder for a matching file whenever a GET request comes in. If a file is found, Express serves it directly without touching any of your route handlers. If it isn't found, the request falls through to whatever comes next in your middleware chain.

const express = require('express');
const path    = require('path');
const app     = express();

// Serve files from the uploads/ directory
// under the URL prefix /uploads
app.use(
  '/uploads',
  express.static(path.join(__dirname, 'uploads'))
);

// Now a file at: uploads/avatars/user-001.jpg
// Is served at:  GET /uploads/avatars/user-001.jpg

Using path.join(__dirname, 'uploads') instead of a relative string is worth noting. Relative paths are resolved from wherever the node process was launched, which can cause confusing "file not found" errors if you start your server from a different directory. __dirname is always the directory of the current file reliable regardless of where you invoke node.

express.static() is essentially a tiny file server baked into your Express app. It handles caching headers, ETags, range requests, and content-type detection things you'd spend hours implementing yourself.

Controlling the URL prefix

The first argument to app.use() sets the URL prefix. You can use any path you like it doesn't have to match the folder name. A user never needs to know your server's folder structure:

// Folder: uploads/
// Public URL prefix: /media
app.use('/media', express.static('uploads'));

// File on disk: uploads/photo.jpg
// Accessed via: GET /media/photo.jpg  

Accessing Uploaded Files via URL

The most common pattern is to return the file's URL immediately after upload, so the client can display or link to it right away. Your route handler should construct the URL from the saved filename:

app.post('/upload/avatar', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'No file received' });
  }

  // Build a public-facing URL from the saved filename
  const fileUrl = `\({req.protocol}://\){req.get('host')}/uploads/${req.file.filename}`;

  // Typically you'd save fileUrl to a database here
  res.status(201).json({
    message: 'Upload successful',
    url:     fileUrl,
  });
});

Security Considerations for Uploads

File uploads are one of the most common attack vectors in web applications. A form that accepts files is essentially an invitation for users to send whatever they want to your server. The defaults are not safe you have to explicitly lock things down.

The non-negotiables

  • Always set a file size limit. Without limits.fileSize, a user can upload a multi-gigabyte file and crash your server.

  • Validate the file type server-side. Checking file.mimetype is a minimum. For sensitive use cases, inspect magic bytes with a library like file-type.

  • Never use the original filename. A user could send ../../../etc/passwd as a filename. Always generate your own โ€” Multer's random hash or your own UUID.

  • Store uploads outside the web root. If your uploads folder is outside public/, it can't be served accidentally. Only expose files you explicitly serve via static middleware.

  • Never execute uploaded files. Even if you're expecting a script file, never run it. Uploaded files should be treated as data โ€” read, stored, downloaded. Never executed.

  • Consider image resizing/re-encoding. A malicious PNG can embed harmful data in metadata or exploit decoder vulnerabilities. Re-encoding images with a library like sharp strips unknown data.

Error handling for rejected uploads

When Multer rejects a file due to file size, bad type, or too many files โ€” it passes an error to the next middleware. You need to catch this explicitly, otherwise Express will respond with a generic 500:

app.post('/upload', (req, res, next) => {
  upload.single('image')(req, res, (err) => {
    if (err instanceof multer.MulterError) {
      // Multer-specific errors (LIMIT_FILE_SIZE, etc.)
      if (err.code === 'LIMIT_FILE_SIZE') {
        return res.status(413).json({ error: 'File too large. Max 5 MB.' });
      }
      return res.status(400).json({ error: err.message });
    }
    if (err) {
      // fileFilter or other custom errors
      return res.status(400).json({ error: err.message });
    }

    // No error โ€” proceed normally
    handleUpload(req, res);
  });
});

function handleUpload(req, res) {
  if (!req.file) return res.status(400).json({ error: 'No file' });
  res.json({ url: `/uploads/${req.file.filename}` });
}

Complete Example: Upload, Store, Serve

Here is a fully working minimal server that combines storage configuration, static file serving, file type filtering, size limits, and proper error handling:

const express = require('express');
const multer  = require('multer');
const path    = require('path');
const fs      = require('fs');

const app        = express();
const UPLOAD_DIR = path.join(__dirname, 'uploads');

// Ensure the uploads folder exists
fs.mkdirSync(UPLOAD_DIR, { recursive: true });

// Storage 
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, UPLOAD_DIR),
  filename:    (req, file, cb) => {
    const ext    = path.extname(file.originalname).toLowerCase();
    const unique = `${Date.now()}-` + Math.random().toString(36).slice(2);
    cb(null, unique + ext);
  },
});

// Multer instance 
const upload = multer({
  storage,
  limits:     { fileSize: 5_000_000 },
  fileFilter: (req, file, cb) => {
    const ok = ['image/jpeg', 'image/png', 'image/webp'];
    cb(ok.includes(file.mimetype) ? null : new Error('Images only'), ok.includes(file.mimetype));
  },
});

// Static serving 
app.use('/uploads', express.static(UPLOAD_DIR));

// Upload route (with error handling) 
app.post('/upload', (req, res) => {
  upload.single('image')(req, res, (err) => {
    if (err) {
      const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
      return res.status(status).json({ error: err.message });
    }
    if (!req.file) {
      return res.status(400).json({ error: 'No file provided' });
    }

    const fileUrl = `/uploads/${req.file.filename}`;
    res.status(201).json({ url: fileUrl });
  });
});

app.listen(3000, () => console.log('Server on :3000'));