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:
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.
app.use("/uploads", express.static("uploads"));
Now any file inside your uploads/ folder is publicly accessible at /uploads/filename
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:
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:
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.
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:
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:
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.
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:
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:
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.
# .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:
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.




