# Storing & Serving Uploaded Files in Express

### **Contents**

1.  Where uploaded files are stored
    
2.  Local storage vs. external storage
    
3.  Serving static files in Express
    
4.  Accessing uploaded files via URL
    
5.  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.

```javascript
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.

![](https://cdn.hashnode.com/uploads/covers/696f9f5589255b82f81f6b17/09d52cec-a31b-4136-8564-0301fd0e7415.png align="center")

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.

> <div data-node-type="callout">
> <div data-node-type="callout-emoji">💡</div>
> <div data-node-type="callout-text"><strong>Rule of thumb:</strong> 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.</div>
> </div>

* * *

## 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.

```javascript
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`](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**

![](https://cdn.hashnode.com/uploads/covers/696f9f5589255b82f81f6b17/80db6d18-9d2c-4110-a563-13e130b01de1.png align="center")

> ⚠️
> 
> **Note:** `express.static()` serves *everything* in the target directory. Never point it directly at your project root or any folder containing sensitive files like `.env` or 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:

```javascript
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:

```javascript
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/passwd` or `shell.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: attachment` **for 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-limit` or 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 with `res.sendFile()`.

### **Protected file serving example**

```javascript
// 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

* * *
