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
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
| 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
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 });
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
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"));
Now, if a file exists at uploads/avatars/user-1.jpg, you can access it at:
http://localhost:3000/avatars/user-1.jpg
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"));
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
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)
└────────────────────────────────────────────────────────┘
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
});
});
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"
}
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" />
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);
}
}
});
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}`);
}
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
}
});
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
- Create a project with an
uploads/folder containing two subfolders:profiles/anddocuments/. - Configure Multer to dynamically save images to
profiles/and PDFs todocuments/. - Generate secure, random filenames for all uploads.
Part 2: Serve the Files
- Use
express.staticto serve theuploads/folder under the/mediaURL prefix. - Ensure
http://localhost:3000/media/profiles/test.jpgworks.
Part 3: Secure the Endpoint
- Add a file size limit of 1MB for profiles and 5MB for documents.
- Add a
fileFilterthat rejects any file that isn't a JPEG, PNG, or PDF.
Key Takeaways
- 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.
- By default, Express keeps your files private. You must explicitly use
app.use(express.static())to expose a folder to the internet. - Use a virtual path prefix (
app.use("/uploads", express.static("uploads"))) to keep URLs organized and clearly map them to local directories. - When saving to a database, store the relative URL path (e.g.,
/uploads/image.jpg), not the absolute local path (C:/Users/...). - 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)