Skip to main content

Command Palette

Search for a command to run...

Storing Uploaded Files and Serving Them in Express

Updated
9 min readView as Markdown

You built the file upload endpoint. Multer is configured, the route is working, files are coming in. You test it with Postman — status 200, no errors.

Then you ask the next question: where did the file actually go? And how does anyone access it?

That is the part most tutorials skip. They show you how to receive a file. They do not explain what happens after — where it lives, how you serve it back, how a browser actually loads it from your server, and what can go wrong if you do not think carefully about any of this.

That is exactly what this covers.

Where Uploaded Files Go ?

When a file comes in through an upload endpoint, it does not magically persist somewhere. You decide where it goes — and that decision has consequences for your entire application.

The most straightforward approach is storing files directly on the server's file system. When you configure Multer with a disk storage destination, uploaded files land in whatever folder you point to.

const multer = require("multer");
const path = require("path");

const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/"); // Files land in the uploads folder
  },
  filename: function (req, file, cb) {
    const uniqueName = Date.now() + "-" + file.originalname;
    cb(null, uniqueName);
  },
});

const upload = multer({ storage });

After a successful upload, the file physically exists on your server's disk at uploads/1700000000000-profile.jpg or whatever name you generated. It is a real file in a real folder. Right now, nothing is serving it to the outside world. It is just sitting there.

Organizing Your Upload Folder

Before you serve anything, think about structure. Dumping every uploaded file into a flat uploads/ folder works fine at first. Three months later, when you have ten thousand files with random names, it becomes harder to reason about, harder to back up selectively, and harder to clean up.

A folder structure organized by purpose is far more maintainable:

project/
├── uploads/
│   ├── profiles/       ← user profile pictures
│   ├── documents/      ← uploaded PDFs or docs
│   └── products/       ← product images
├── routes/
├── index.js
└── package.json

When you configure Multer, you point it at the right subfolder based on what is being uploaded:

const profileStorage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/profiles/");
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + "-" + file.originalname);
  },
});

const documentStorage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, "uploads/documents/");
  },
  filename: function (req, file, cb) {
    cb(null, Date.now() + "-" + file.originalname);
  },
});

const uploadProfile = multer({ storage: profileStorage });
const uploadDocument = multer({ storage: documentStorage });

Now each type of file has its own home. When something goes wrong — a corrupt file, a file you need to delete, a folder you need to audit — you know exactly where to look.

Local Storage vs External Storage

Local storage means files live on the same server your Express app runs on. It is the simplest setup and requires no third-party services. For learning, small projects, or internal tools, it is perfectly fine.

But it comes with real limitations that matter the moment your application grows.

If your server goes down and comes back up with a fresh disk — your files are gone. If you scale horizontally and run three instances of your app behind a load balancer — a file uploaded to instance one does not exist on instances two or three. If you need to back up files independently of your application — local storage makes that harder. The files are tied to the machine your app runs on, and that is a fragile dependency.

External storage solves all of this. Services like AWS S3, Cloudinary, or Google Cloud Storage store files completely separately from your server. Your Express app receives the upload, forwards the file to the storage service, and gets back a URL. The file lives in the cloud. Your server holds nothing.

Local Storage:
  Client → Express Server → /uploads folder on same machine

External Storage:
  Client → Express Server → AWS S3 / Cloudinary → URL stored in database

The tradeoff is complexity and cost. External storage requires API keys, SDK configuration, and a paid account once you pass free tier limits. For a production application handling user-generated content, it is almost always the right choice. For learning and small projects, local storage gets you there without the overhead.

Serving Static Files in Express

Here is the key concept: storing a file and serving a file are two separate things.

A file sitting in your uploads/ folder is not accessible to anyone until you explicitly tell Express to serve that folder. By default, Express does not expose any part of your file system to the outside world.

express.static() is the built-in middleware that changes that. You tell it which folder to serve, and Express maps that folder to a URL path.

const express = require("express");
const app = express();

// Serve everything inside the "uploads" folder at the "/uploads" URL path
app.use("/uploads", express.static("uploads"));

After this one line, any file inside your uploads/ folder becomes accessible via a URL. A file at uploads/profiles/1700000000000-photo.jpg is now reachable at:

http://localhost:3000/uploads/profiles/1700000000000-photo.jpg

Express receives that request, matches the /uploads prefix, looks inside the uploads folder for the rest of the path, finds the file, and streams it back to the browser. You did not write a single route handler for this — express.static() handles the entire thing.

Returning the File URL After Upload

When a file is uploaded, you want to store a reference to it — usually in your database — and return the accessible URL to the client. Your upload endpoint should build that URL and send it back.

app.post("/upload/profile", uploadProfile.single("image"), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ message: "No file uploaded" });
  }

  // Build the public URL for this file
  const fileUrl = `\({req.protocol}://\){req.get("host")}/uploads/profiles/${req.file.filename}`;

  // In a real app, you would save fileUrl to your database here
  // await User.updateOne({ _id: req.user.id }, { profilePicture: fileUrl });

  res.status(200).json({
    message: "File uploaded successfully",
    url: fileUrl,
  });
});

req.protocol gives you http or https. req.get("host") gives you localhost:3000 in development or your actual domain in production. req.file.filename is the name Multer assigned to the file on disk. Put them together and you have a fully formed, working URL that anyone can use to load that file.

The client receives this URL and can use it immediately — display it as an image, store it, share it. The URL is the handle to the file.

Verifying It Works

A simple test to confirm your setup is correct — upload a file, take the URL from the response, and paste it directly into a browser. If the file loads, your static serving is working. If you get a 404, the path in your express.static() call does not match where the file actually landed.

A common mistake is path mismatch. If your app is running from inside a subfolder and you use a relative path like "uploads", Express resolves it relative to wherever you launched Node from — not relative to your file. Using path.join(__dirname, "uploads") is more reliable because it always resolves to an absolute path based on where the file lives.

const path = require("path");

app.use("/uploads", express.static(path.join(__dirname, "uploads")));

This small change eliminates an entire category of confusing bugs where files upload successfully but cannot be served.

Security Considerations

An open file upload endpoint on a production server is one of the most commonly exploited vulnerabilities in web applications. Before you ship anything, you need to think through at least three things.

Validate file types. Never trust the file extension the client sends. A malicious user can rename a script to photo.jpg and upload it. Validate the MIME type on the server using Multer's fileFilter:

const imageOnlyStorage = multer({
  storage: storage,
  fileFilter: function (req, file, cb) {
    const allowedTypes = ["image/jpeg", "image/png", "image/webp"];

    if (!allowedTypes.includes(file.mimetype)) {
      return cb(new Error("Only image files are allowed"), false);
    }

    cb(null, true);
  },
});

Only files with an explicitly allowed MIME type get through. Everything else is rejected before it touches your disk.

Limit file size. Without a size limit, a single user can upload a 2GB video and consume your server's disk space or crash your process. Set a hard limit:

const upload = multer({
  storage: storage,
  limits: {
    fileSize: 5 * 1024 * 1024, // 5MB maximum
  },
});

Multer will reject anything over this size with a clear error before writing anything to disk.

Never trust filenames from the client. The original filename the user sends can contain path traversal characters — things like ../../etc/passwd — designed to escape your uploads folder and write files somewhere they should not go. Never use file.originalname directly as the stored filename. Always generate your own:

filename: function (req, file, cb) {
  const extension = path.extname(file.originalname); // Get just the extension
  const safeName = Date.now() + "-" + Math.round(Math.random() * 1e9) + extension;
  cb(null, safeName);
}

You keep the extension for the browser to know how to render the file, but the rest of the filename is something you generated — random, unique, and free of anything the client injected.

The Bigger Picture

Local file storage with express.static() is a complete solution for development and small-scale applications. Understanding how it works — how files land on disk, how Express maps a folder to a URL, how you construct and return that URL, how you keep the endpoint from being abused — gives you the foundation to reason about file handling in any context.

When you eventually move to external storage like S3, the same mental model applies. Files go somewhere. You get back a URL. You store that URL. You serve the URL to clients. The mechanics change, the infrastructure changes — but the thinking is identical. Getting the local version right is what makes the cloud version make sense.

1 views