DEV Community

Cover image for Storing Uploaded Files and Serving Them in Express
Pratham
Pratham

Posted on

Storing Uploaded Files and Serving Them in Express

Where do files go after you upload them, and how do you get them back?


You've set up Multer. You've sent a POST request with an image. The server didn't crash, and you got a success response. But... where did the file actually go? And more importantly, how do you show that uploaded profile picture on your frontend?

When I first learned file uploads in the ChaiCode Web Dev Cohort 2026, I thought the hard part was receiving the file. It turned out the trickiest part was figuring out how to store it securely and serve it back to the client.

If you don't handle this correctly, your server's hard drive will fill up with junk, your URLs will break, or worse — you'll expose a massive security vulnerability. Let's break down exactly how to store and serve files in Express.


Where Are Uploaded Files Stored?

When a user uploads a file, the server has to put the binary data somewhere. You essentially have two options: Local Storage or External Storage.

Local Storage (The Default)

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

Your Server:
  app.js
  package.json
  node_modules/
  uploads/          ← Files go here
    profile-1.jpg
    doc-42.pdf
Enter fullscreen mode Exit fullscreen mode

This is how Multer works out of the box when you use dest: 'uploads/'.

Pros Cons
Super easy to set up If your server crashes/restarts (like on Heroku), files are deleted
Zero external dependencies Doesn't scale (what if you have 3 load-balanced servers?)
Fast read/write times Server hard drive can fill up quickly
Perfect for learning Not ideal for modern production apps

External Storage (Cloud)

External storage means sending the file to a dedicated storage service (like Amazon S3, Google Cloud Storage, or Cloudinary) instead of keeping it on your server.

Your Server:
  (Receives file) → (Uploads to Cloud) → (Saves Cloud URL to Database)

AWS S3 / Cloudinary:
  profile-1.jpg  ← Files live here
Enter fullscreen mode Exit fullscreen mode
Pros Cons
Infinite scalability Harder to set up
Files survive server restarts Costs money (usually)
Reduces load on your Express server Requires managing API keys and SDKs
CDN integration for fast global delivery Adds latency to the upload process

For this article, we'll focus on Local Storage. It's the foundation you must understand before moving to the cloud.


The Upload Storage Folder Structure

When storing files locally, you need a predictable, organized folder structure. Dumping 100,000 files into a single uploads/ folder will eventually cause performance issues on your OS.

Here is a clean, production-ready local storage structure:

project-root/
├── app.js
├── public/                 ← Static files you ship with your app
│   ├── css/
│   └── images/
└── uploads/                ← Files uploaded by users (excluded from git)
    ├── avatars/            ← Grouped by resource type
    │   ├── user-1-1715.jpg
    │   └── user-2-1715.png
    └── documents/
        └── invoice-88.pdf
Enter fullscreen mode Exit fullscreen mode

Multer Storage Configuration

To achieve this, we use Multer's diskStorage to dynamically route files to the correct folder:

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

// Ensure folders exist (create them if they don't)
const avatarDir = path.join(__dirname, "uploads/avatars");
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    // Route files based on the field name
    if (file.fieldname === "avatar") {
      cb(null, "uploads/avatars/");
    } else {
      cb(null, "uploads/misc/");
    }
  },
  filename: (req, file, cb) => {
    // Unique name: timestamp-random-originalExt
    const ext = path.extname(file.originalname);
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    cb(null, `avatar-${uniqueSuffix}${ext}`);
  }
});

const upload = multer({ storage });
Enter fullscreen mode Exit fullscreen mode

Serving Static Files in Express

Okay, the file is saved at uploads/avatars/user-1.jpg. Now, your React frontend requests:

GET http://localhost:3000/uploads/avatars/user-1.jpg
Enter fullscreen mode Exit fullscreen mode

If you try this, Express will return a 404 Not Found.

Why? Because Express is secure by default. It does not expose your server's file system to the internet. If it did, someone could request http://localhost:3000/app.js or http://localhost:3000/.env and steal your source code.

To make a folder accessible to the public, you have to explicitly tell Express to serve it using the express.static middleware.

The express.static Middleware

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

// Tell Express: "Make everything inside the 'uploads' folder publicly accessible"
app.use(express.static("uploads"));
Enter fullscreen mode Exit fullscreen mode

Now, if a file exists at uploads/avatars/user-1.jpg, you can access it at:

http://localhost:3000/avatars/user-1.jpg
Enter fullscreen mode Exit fullscreen mode

Notice that "uploads" is missing from the URL. express.static("uploads") mounts the contents of the folder at the root of your domain.

Virtual Path Prefix (The Better Way)

Usually, you want the URL to explicitly show that the file is an upload. You can define a "virtual" path prefix:

// Map the URL path '/uploads' to the local folder 'uploads'
app.use("/uploads", express.static("uploads"));
Enter fullscreen mode Exit fullscreen mode

Now the URL perfectly matches the folder structure:

Local File: project-root/uploads/avatars/user-1.jpg
URL:        http://localhost:3000/uploads/avatars/user-1.jpg
Enter fullscreen mode Exit fullscreen mode

Static File Serving Flow

Here is exactly what happens when a browser requests an image:

Browser requests: GET /uploads/avatars/user-1.jpg
                │
                ↓
┌────────────────────────────────────────────────────────┐
│                   EXPRESS PIPELINE                     │
│                                                        │
│  1. app.use("/uploads", express.static("uploads"))     │
│     → URL starts with "/uploads"? YES.                 │
│     → Strips "/uploads" from URL.                      │
│     → Looks for "avatars/user-1.jpg" inside local      │
│       "uploads" directory.                             │
│                                                        │
│  2. Does the file exist on the hard drive?             │
│                                                        │
│     YES ───────────────┐           NO ──────────────┐  │
│                        │                            │  │
│     Stream file        │           Call next()      │  │
│     to client          │           to continue      │  │
│     (Don't call next)  │           routing          │  │
│                        ↓                            ↓  │
│               Response sent: 200 OK          Route not found (404)
└────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Accessing Uploaded Files via URL

When a user uploads a file, you need to save the URL (or relative path) in your database so you can send it back to the frontend later.

Saving the Path to the Database

app.post("/api/users/:id/avatar", upload.single("avatar"), async (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: "Please upload a file" });
  }

  const userId = req.params.id;

  // Construct the relative path
  // req.file.filename is "avatar-12345.jpg"
  const avatarPath = `/uploads/avatars/${req.file.filename}`;

  // Save this path to your database
  // await User.update(userId, { avatarUrl: avatarPath });

  res.json({
    message: "Avatar updated",
    avatarUrl: avatarPath, // Send back the URL for immediate display
  });
});
Enter fullscreen mode Exit fullscreen mode

Displaying on the Frontend

When the frontend fetches the user profile, it gets the path:

{
  "id": 42,
  "name": "Pratham",
  "avatarUrl": "/uploads/avatars/avatar-12345.jpg"
}
Enter fullscreen mode Exit fullscreen mode

The frontend (React, Vue, HTML) simply plugs that into an <img> tag:

<!-- Assuming the API and frontend are on the same domain -->
<img src="/uploads/avatars/avatar-12345.jpg" alt="Profile Picture" />

<!-- If API is on a different domain -->
<img src="http://localhost:3000/uploads/avatars/avatar-12345.jpg" />
Enter fullscreen mode Exit fullscreen mode

Security Considerations for Uploads

Handling user uploads is inherently dangerous. If you let users upload anything and then serve it back, you are inviting attackers to compromise your server.

Here are the absolute non-negotiables for secure file handling:

1. Never Trust the Extension

A user can rename malware.exe to innocent.jpg. Checking the extension is not enough. You must validate the MIME type.

const upload = multer({
  fileFilter: (req, file, cb) => {
    // Check MIME type, not just extension
    const allowedMimeTypes = ["image/jpeg", "image/png", "image/webp"];

    if (allowedMimeTypes.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type"), false);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

2. Never Trust the Filename

Never use file.originalname to save the file. An attacker could name their file ../../../etc/passwd to overwrite critical server files (Directory Traversal attack).

Always generate a random, unique filename on the server.

// ❌ DANGEROUS:
filename: (req, file, cb) => cb(null, file.originalname)

// ✅ SECURE:
filename: (req, file, cb) => {
  const ext = path.extname(file.originalname);
  cb(null, `${Date.now()}-${Math.random().toString(36).substring(7)}${ext}`);
}
Enter fullscreen mode Exit fullscreen mode

3. Limit File Size

Without a size limit, an attacker can upload a 50GB file and crash your server by filling up the disk or exhausting memory.

const upload = multer({
  limits: {
    fileSize: 2 * 1024 * 1024, // 2 MB maximum
  }
});
Enter fullscreen mode Exit fullscreen mode

4. Prevent Execution

If you allow users to upload HTML or SVG files, an attacker can inject malicious JavaScript into them. When another user views that image, the script runs (Stored XSS).

  • Only allow safe image types (JPG, PNG, WebP).
  • Be extremely careful with PDFs or SVGs.
  • When in doubt, force downloads rather than displaying inline.

Let's Practice: Hands-On Assignment

Part 1: Build the Storage Architecture

  1. Create a project with an uploads/ folder containing two subfolders: profiles/ and documents/.
  2. Configure Multer to dynamically save images to profiles/ and PDFs to documents/.
  3. Generate secure, random filenames for all uploads.

Part 2: Serve the Files

  1. Use express.static to serve the uploads/ folder under the /media URL prefix.
  2. Ensure http://localhost:3000/media/profiles/test.jpg works.

Part 3: Secure the Endpoint

  1. Add a file size limit of 1MB for profiles and 5MB for documents.
  2. Add a fileFilter that rejects any file that isn't a JPEG, PNG, or PDF.

Key Takeaways

  1. Local storage saves files to your server's disk. It's easy but doesn't scale well. External storage (S3) is the industry standard for production.
  2. By default, Express keeps your files private. You must explicitly use app.use(express.static()) to expose a folder to the internet.
  3. Use a virtual path prefix (app.use("/uploads", express.static("uploads"))) to keep URLs organized and clearly map them to local directories.
  4. When saving to a database, store the relative URL path (e.g., /uploads/image.jpg), not the absolute local path (C:/Users/...).
  5. Security is critical: Never trust user-provided filenames, always validate MIME types (not just extensions), and enforce strict file size limits.

Wrapping Up

Handling the upload is only half the battle. Storing the file securely, organizing your directories, and cleanly serving the assets back to the client is what turns a quick script into a robust application.

I'm learning all of this through the ChaiCode Web Dev Cohort 2026 under Hitesh Chaudhary and Piyush Garg. The moment I finally saw a profile picture I uploaded render back on the frontend using express.static, the whole full-stack cycle finally clicked. It's not magic; it's just moving data, saving it to disk, and giving it a public URL.

Connect with me on LinkedIn or visit PrathamDEV.in. More articles on the way.

Happy coding! 🚀


Written by Pratham Bhardwaj | Web Dev Cohort 2026, ChaiCode

Top comments (0)