Skip to main content

Command Palette

Search for a command to run...

Storing Uploaded Files and Serving Them in Express

Updated
•6 min read•View as Markdown

If you've ever built a web app that lets users update a profile picture, submit a PDF report, or share a video, you've hit the inevitable hurdle: handling file uploads.

Receiving a JSON payload is straightforward. Receiving a 5MB image file? That requires a bit more orchestration. As developers, we have to figure out where that file goes once it hits our backend, how to keep it secure, and how to eventually show it back to the user on the frontend.

Let's break down exactly how to manage uploaded files in a Node.js and Express environment, from the moment it hits your server to the moment it renders in a browser.

1. Where Do Uploaded Files Actually Go?

When a user hits "submit" on a file input, the file travels via an HTTP POST request (specifically as multipart/form-data) to your Express server. But Express doesn't magically know what to do with that data stream out of the box. You typically use middleware like multer or express-fileupload to intercept the incoming file, parse it, and save it.

But save it where? You have two primary architectural choices:

Local Storage (The Quick Start)

This means saving the file directly onto the hard drive of the server running your Express app.

  • The Good: It’s incredibly easy to set up. You just designate a folder (like /uploads), and write the file stream there.

  • The Catch: It doesn't scale well. If you spin up multiple instances of your server behind a load balancer, an image uploaded to Server A won't be found if the next request routes to Server B. If your server dies, your files might die with it.

External/Cloud Storage (The Production Standard)

Instead of saving the file to your server's disk, your Express app acts as a middleman. It receives the file in memory (or temporarily on disk) and immediately pipes it to an external service like Amazon S3, Google Cloud Storage, or Cloudinary.

  • The Good: Infinite scalability. Your Node servers remain stateless. Content Delivery Networks (CDNs) can be easily attached to serve these files globally at high speeds.

  • The Catch: Slightly more complex to set up and requires managing third-party API keys and permissions.

2. Structuring Local Storage

If you are building a smaller app, an MVP, or an internal tool, local storage is perfectly fine. Keeping your project organized is key so your root directory doesn't turn into a dumping ground.

A standard, clean folder structure looks like this:

my-express-app/
├── node_modules/
├── src/
│   ├── controllers/
│   ├── routes/
│   └── server.js
├── public/           <-- Everything in here is accessible to the outside world
│   ├── css/
│   ├── js/
│   └── uploads/      <-- Your uploaded files live here
│       ├── users/
│       └── products/
├── package.json
└── .env

Notice that we isolate the uploads folder inside a public directory. This separation of concerns is vital because you never want to accidentally expose your backend source code (like server.js or .env) to the public internet.

3. Serving Static Files in Express

Storing the file is only half the battle. If a user uploads an avatar named profile.jpg into your public/uploads/users directory, how do they actually view it in their browser?

By default, Express strictly protects your file system. If a user navigates to yoursite.com/uploads/users/profile.jpg, Express will throw a 404 error. It doesn't matter if the file exists on the disk; if you haven't explicitly told Express to serve it, the door is locked.

To unlock the door for specific folders, we use the built-in express.static middleware.

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

// Tell Express to serve everything inside the 'public' folder
app.use(express.static(path.join(__dirname, 'public')));

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

How this works under the hood

When a request comes in, the express.static middleware acts like a bouncer checking a VIP list. It intercepts the HTTP request, strips away the domain, takes the remaining URL path, and checks if a file matches that path inside your public folder. If it finds a match, it automatically streams the file back to the client with the correct HTTP headers (like Content-Type: image/jpeg). If it doesn't, it simply passes the request to your next route handler.

4. Accessing Uploaded Files via URL

Because we pointed express.static at the public directory, that directory becomes the "root" for incoming static requests.

This trips up many developers early on. You do not include the word public in the URL.

  • File Location on Server Disk: my-express-app/public/uploads/users/avatar.png

  • URL to Access It: http://localhost:3000/uploads/users/avatar.png

If you wanted to mask the /uploads path entirely, you can mount the static middleware to a virtual path:

// Now, files in public/uploads are accessed via /media in the URL
app.use('/media', express.static(path.join(__dirname, 'public/uploads')));
  • New URL: http://localhost:3000/media/users/avatar.png

5. Security Considerations for Uploads

Handling user-generated files is one of the biggest security attack vectors in web development. Never trust a file simply because it has a .png extension.

Here are the non-negotiables for safe file handling:

  1. Validate File Types: Don't just check the file extension (anyone can rename virus.exe to virus.jpg). Use a library that checks the file's "magic numbers" (the actual raw byte header of the file) to ensure an image is truly an image.

  2. Enforce File Size Limits: Attackers can easily write a script to upload a 50GB file to exhaust your server's memory or disk space (a Denial of Service attack). Always set strict limits in your upload middleware.

  3. Rename Files on Upload: Never save a file using its original name. A user might upload a file named ../../../etc/passwd to attempt a directory traversal attack, or simply upload a file with a name that overwrites an existing system file. Always generate a unique, random string (like a UUID) for the saved filename.

  4. Prevent Execution: If you are using local storage, ensure the directory where files are served from does not have execution permissions. A user should never be able to upload a .js or .sh script and then navigate to its URL to run it on your server.

Handling file uploads in Node.js requires a careful balance between user experience and system architecture. Whether you route files directly to a cloud bucket or serve them locally using express.static, maintaining a strict boundary around what enters your system is the key to a stable backend.