# Storing Uploaded Files and Serving Them in Express

So in the last post I covered how Multer handles file uploads parsing the multipart request, pulling out the file, saving it to disk. But once a file is saved, two questions come up immediately.

Where exactly does it live? And how does someone actually access it?

That's what this post is about.

## Where Files Go After Upload

When you configure Multer with a destination folder:

```javascript
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => cb(null, Date.now() + "-" + file.originalname)
});
```

The file gets written to that `uploads/` folder on your server's filesystem. Right next to your `index.js`, your `routes/` folder, your `node_modules/` just sitting there on disk.

The files are there. But right now, nobody can access them through a URL. They're on the filesystem — not exposed to the web. A user hitting `http://localhost:3000/uploads/1714823400000-profile.jpg` would just get a 404.

That's where static file serving comes in.

## Serving Static Files With Express

Express has a built-in middleware called `express.static()`. Give it a folder name and it exposes everything in that folder over HTTP.

```javascript
app.use("/uploads", express.static("uploads"));
```

Now any file inside your `uploads/` folder is publicly accessible at `/uploads/filename`

```plaintext
http://localhost:3000/uploads/1714823400000-profile.jpg  
http://localhost:3000/uploads/1714823600000-banner.png   
```

The first argument to `app.use()` is the URL prefix. The argument to `express.static()` is the actual folder on disk. They don't have to match you could serve the `uploads` folder at `/files` or `/media` or anything else:

```javascript
app.use("/media", express.static("uploads"));
// now files are at /media/filename, not /uploads/filename
```

But keeping them the same is the simplest and most common approach.

## The Upload and Serve Flow Together

Here's the complete flow user uploads a file, server saves it, server responds with the URL:

```javascript
const express = require("express")
const multer = require("multer")
const path = require("path")

const app = express()

app.use("/uploads", express.static("uploads"))

const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, "uploads/"),
  filename: (req, file, cb) => {
    const uniqueName = Date.now() + path.extname(file.originalname)
    cb(null, uniqueName)
  }
})

const upload = multer({ storage })

app.post("/upload", upload.single("file"), (req, res) => {
  const fileUrl = `http://localhost:3000/uploads/${req.file.filename}`
  res.json({
    message: "Uploaded successfully",
    url: fileUrl
  })
})

app.listen(3000)
```

Client uploads Multer saves to `uploads/` server sends back the URL client can hit that URL and get the file.

That URL goes to the database. Every time the client wants to display the file, it uses that URL.

## Local Storage vs External Storage

What you've just set up is **local storage** files live on the same server running your Node app. Simple, fast to set up, works great for development and small projects.

But it has real limitations in production:

**Scaling problem** If you run multiple server instances (which most deployed apps do), each server has its own uploads/ folder. A file uploaded to server 1 isn't on server 2. A request routed to server 2 can't find it.

**Persistence problem** Many cloud platforms (Render, Railway, Heroku) have ephemeral filesystems. When the server restarts or redeploys, the disk resets. Your uploads/ folder gets wiped. Every uploaded file is gone.

**Storage limit** Your server disk has a size. It's not a good place to store large volumes of files long-term.

For production, the standard solution is **external storage** a dedicated service designed to store files. AWS S3, Cloudinary, Supabase Storage, Cloudflare R2. Files live there, not on your server. Your server just handles the upload logic and hands the file off.

But for learning, local storage is the right place to start. Get the pattern working locally, then the switch to external storage is mostly just changing where Multer sends the file.

## Organizing Your Uploads Folder

Dumping all files into one flat folder works but gets messy fast. Better to organize by type or purpose from the start.

```javascript
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    let folder = "uploads/misc"

    if (file.mimetype.startsWith("image/")) {
      folder = "uploads/images"
    } else if (file.mimetype === "application/pdf") {
      folder = "uploads/documents"
    }

    cb(null, folder)
  },
  filename: (req, file, cb) => {
    cb(null, Date.now() + path.extname(file.originalname)
  }
})
```

Serve all of them under one prefix:

```javascript
app.use("/uploads", express.static("uploads"))
```

Now images live at `/uploads/images/filename.jpg` and documents at `/uploads/documents/filename.pdf`. Cleaner structure, easier to manage.

## Security Considerations

This is the part that's easy to skip but actually matters a lot.

**Validate file types**

Never trust `file.mimetype` alone it comes from the request headers and can be spoofed. Check the file extension too, and ideally validate the actual file content. At minimum, do both mimetype and extension:

```javascript
const fileFilter = (req, file, cb) => {
  const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
  const allowedExtensions = [".jpg", ".jpeg", ".png", ".webp"]
  const ext = path.extname(file.originalname).toLowerCase()

  if (allowedTypes.includes(file.mimetype) && allowedExtensions.includes(ext)) {
    cb(null, true)
  } else {
    cb(new Error("Only JPG, PNG and WebP images allowed"), false);
  }
}
```

**Set file size limits**

Without a limit, someone can upload a 4GB file and bring your server to its knees.

```javascript
const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 } // 5MB max
})
```

**Never use the original filename directly**

file.originalname is whatever the user named their file. Someone could name a file ../../etc/passwd or script.php and try to do something malicious with it. Always generate your own filename:

```javascript
filename: (req, file, cb) => {
  const uniqueName = Date.now() + "-" + Math.round(Math.random() * 1e9) + path.extname(file.originalname)
  cb(null, uniqueName)
}
```

Your own timestamp-based name with the original extension. Predictable format, unpredictable name, nothing the user controls.

**Don't serve your uploads folder for executable files**

If you're accepting uploads other than images, be careful about what express.static() serves. A .html file served as static content will render in the browser. A .js file is executable. If you must accept non-image uploads, serve them as downloads with explicit headers rather than letting the browser handle them however it wants:

```js
app.get("/download/:filename", (req, res) => {
  const filename = path.basename(req.params.filename)
  const filepath = path.join(__dirname, "uploads/documents", filename)
  res.download(filepath)
})
```

path.basename() strips any directory components from the filename — so if someone passes ../../etc/passwd as the filename, it becomes just passwd, which won't exist in your documents folder. res.download() forces the browser to download the file instead of trying to render it.

**Add the uploads folder to** .gitignore

Uploaded files should not go into version control. They belong on the server, not in your repo.

```plaintext
# .gitignore
uploads/
```

Keep the folder itself in your project structure — just not its contents.

## Making Sure the Folder Exists

One small thing that catches people if the `uploads/` folder doesn't exist when Multer tries to write to it, it throws an error. Create it as part of your app setup:

```javascript
const fs = require("fs")

const uploadDir = "uploads"
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true })
}
```

Put this near the top of your `index.js`. Now even if someone clones your project fresh or deploys to a new server, the folder gets created automatically on startup.

Local disk storage is the right place to learn this pattern. Once it's working end-to-end — upload lands on disk, URL comes back, file is accessible — you understand the full cycle. Swapping to S3 or Cloudinary later is just changing the storage destination, not the overall flow.

Keep the security basics in mind from the start. Validate types, limit sizes, control filenames. File uploads are one of the most common attack surfaces in web apps — small habits now save big headaches later.
