Skip to main content

Command Palette

Search for a command to run...

Storing Uploaded Files and Serving Them in Express

Updated
12 min read

Accepting a file upload is just the first step. Knowing where to put it, how to name it safely, and how to serve it back, that's what makes it production-ready.


The Two-Part Problem

When a user uploads a file, a profile picture, a PDF, a document, your Express server needs to solve two separate problems:

  1. Where does the file go? You need to store it somewhere it won't get lost.

  2. How does anyone access it later? A stored file is useless unless something can serve it back.

These are independent concerns. You can store a file locally and serve it remotely. You can store it in the cloud and serve it through a CDN. Understanding them separately makes both clearer.


Where Uploaded Files Go: The Basics

When a file arrives at your Express server, it comes through an HTTP request as a multipart form submission. By default, Express has no built-in ability to handle this, you need middleware.

Multer is the standard choice for handling file uploads in Express:

npm install multer

At its simplest, Multer takes an incoming file and saves it to a folder on your server's disk:

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

const app = express();

// Configure where files go and what they're named
const storage = multer.diskStorage({
  destination: function(req, file, callback) {
    callback(null, "uploads/"); // folder where files are saved
  },
  filename: function(req, file, callback) {
    // Create a unique filename: timestamp + original name
    const uniqueName = Date.now() + "-" + file.originalname;
    callback(null, uniqueName);
  }
});

const upload = multer({ storage });

// Route that accepts a single file under the field name "avatar"
app.post("/upload", upload.single("avatar"), function(req, res) {
  if (!req.file) {
    return res.status(400).json({ error: "No file uploaded" });
  }

  res.json({
    message: "File uploaded successfully",
    filename: req.file.filename,
    path: req.file.path
  });
});

app.listen(3000);

After a successful upload, your folder structure looks like this:

project/
├── server.js
├── uploads/
│   ├── 1706123456789-profile.jpg
│   ├── 1706123487654-resume.pdf
│   └── 1706123512345-avatar.png

Each file gets a timestamp prefix, making collisions nearly impossible. The original name is preserved for reference, but the actual file lives under the timestamped name.


Local Storage vs External Storage

Before going further, it's worth understanding the broader landscape of where files can live.

Local Storage (Disk)

The server saves files directly to its own filesystem, a folder like uploads/. This is what Multer does by default.

The straightforward case — single server:

User → Express Server → /uploads/file.jpg (on same machine)
                     ← /uploads/file.jpg (served back)

Works perfectly on a single server. Simple to set up. Zero additional cost.

The problem — multiple servers:

User uploads to Server A → /uploads/file.jpg
User requests file    → Load balancer → Server B
Server B: /uploads/file.jpg does not exist ← 404

If your app runs on more than one server, for scaling, for redundancy, files saved on one server don't automatically appear on others. This is the fundamental limitation of local file storage.

Local storage is the right choice when:

  • You're building a prototype or internal tool

  • You run on a single server and don't plan to scale

  • Files are temporary (processing and then discarding)

External Storage (Cloud)

Files go to a dedicated storage service, AWS S3, Cloudflare R2, Google Cloud Storage, instead of the server's disk. Every server in your cluster points to the same storage bucket.

User → Any Express Server → AWS S3 bucket
User requests file → CDN → AWS S3 bucket (no matter which server)

Multer supports this through storage engine packages like multer-s3:

const multerS3 = require("multer-s3");
const { S3Client } = require("@aws-sdk/client-s3");

const s3 = new S3Client({ region: "ap-south-1" });

const upload = multer({
  storage: multerS3({
    s3,
    bucket: "my-app-uploads",
    metadata: function(req, file, callback) {
      callback(null, { fieldName: file.fieldname });
    },
    key: function(req, file, callback) {
      const uniqueName = Date.now() + "-" + file.originalname;
      callback(null, uniqueName);
    }
  })
});

app.post("/upload", upload.single("avatar"), function(req, res) {
  res.json({
    message: "Uploaded to S3",
    url: req.file.location // S3 returns a public URL directly
  });
});

External storage is the right choice when:

  • You run on multiple servers or serverless functions

  • You need files to persist if a server is replaced or restarted

  • You want a CDN to serve files close to users globally

  • Storage and compute should scale independently

For production applications serving real users, external storage is almost always the better long-term decision. For learning and early development, local storage is completely fine.


Serving Static Files in Express

Once files are on disk, you need Express to serve them. The simplest way is express.static, built-in middleware that maps a URL path to a folder on disk:

app.use("/files", express.static("uploads"));

This single line tells Express: "Any request to /files/..., find the matching file in the uploads/ folder and serve it."

Request:  GET /files/1706123456789-profile.jpg
Finds:    uploads/1706123456789-profile.jpg
Response: the image file, with appropriate Content-Type

Here's the complete setup together:

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

const app = express();

// Serve the uploads folder at /files
app.use("/files", express.static("uploads"));

// Configure Multer
const storage = multer.diskStorage({
  destination: "uploads/",
  filename: (req, file, cb) => {
    cb(null, Date.now() + "-" + file.originalname);
  }
});
const upload = multer({ storage });

// Upload route — saves file and returns URL
app.post("/upload", upload.single("photo"), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: "No file received" });
  }

  const fileUrl = `http://localhost:3000/files/${req.file.filename}`;

  res.json({
    message: "Upload successful",
    url: fileUrl
  });
});

app.listen(3000, () => console.log("Server running on port 3000"));

After uploading profile.jpg, the server responds with:

{
  "message": "Upload successful",
  "url": "http://localhost:3000/files/1706123456789-profile.jpg"
}

And a browser visiting that URL gets the image directly. express.static handles the Content-Type header, caching headers, and file streaming automatically.


Accessing Uploaded Files via URL

The URL structure for uploaded files follows directly from how you set up express.static:

// If you set up:
app.use("/files", express.static("uploads"));

// Then a file saved at:
uploads/1706123456789-profile.jpg

// Is accessible at:
http://yourdomain.com/files/1706123456789-profile.jpg

You can organize uploads into subfolders by type, and adjust the static serving accordingly:

const storage = multer.diskStorage({
  destination: function(req, file, callback) {
    // Route different file types to different folders
    if (file.mimetype.startsWith("image/")) {
      callback(null, "uploads/images/");
    } else if (file.mimetype === "application/pdf") {
      callback(null, "uploads/documents/");
    } else {
      callback(null, "uploads/other/");
    }
  },
  filename: function(req, file, callback) {
    callback(null, Date.now() + "-" + file.originalname);
  }
});

// Serve all of uploads/ under /files
app.use("/files", express.static("uploads"));

With this structure, files land in organized subfolders:

uploads/
├── images/
│   └── 1706123456789-profile.jpg   → /files/images/1706123456789-profile.jpg
├── documents/
│   └── 1706123487654-resume.pdf    → /files/documents/1706123487654-resume.pdf
└── other/
    └── 1706123512345-data.csv      → /files/other/1706123512345-data.csv

Storing File URLs in a Database

In practice, when a user uploads a file, you'll save its URL to a database alongside the record it belongs to:

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

  const fileUrl = `/files/${req.file.filename}`;

  // Save the URL to the user's record in your database
  await db.query(
    "UPDATE users SET avatar_url = ? WHERE id = ?",
    [fileUrl, req.user.id]
  );

  res.json({ avatarUrl: fileUrl });
});

Later, when you load the user from the database, you get the URL back and can construct the full link:

// In a GET route
const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
const fullAvatarUrl = `https://yourdomain.com${user.avatar_url}`;

Security Considerations for Uploads

File uploads are one of the most commonly exploited attack surfaces in web applications. A few essential safeguards:

1. Validate File Type — Don't Trust the Extension

File extensions can be faked. A file named malware.jpg might actually be a JavaScript file or an executable. Check the MIME type, and if you're serious about it, check the file's actual magic bytes:

const upload = multer({
  storage,
  fileFilter: function(req, file, callback) {
    // Only accept images
    const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];

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

    callback(null, true); // accept the file
  }
});

MIME type is better than extension, but a determined attacker can spoof MIME types too. For highest security, use a library like file-type to inspect the actual file content:

const { fileTypeFromBuffer } = require("file-type");

app.post("/upload", upload.single("file"), async (req, res) => {
  const buffer = require("fs").readFileSync(req.file.path);
  const type = await fileTypeFromBuffer(buffer);

  if (!type || !type.mime.startsWith("image/")) {
    require("fs").unlinkSync(req.file.path); // delete the bad file
    return res.status(400).json({ error: "Invalid file type" });
  }

  res.json({ message: "Valid image accepted" });
});

2. Limit File Size

Without a size limit, a single request could consume all available disk space or memory:

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

Multer rejects oversized files automatically and returns an error. Handle it in your error middleware:

app.use(function(err, req, res, next) {
  if (err.code === "LIMIT_FILE_SIZE") {
    return res.status(400).json({ error: "File too large. Maximum size is 5MB." });
  }
  next(err);
});

3. Never Use the Original Filename Directly

User-controlled filenames are dangerous. A filename like ../../server.js could potentially overwrite server files if used directly in file system operations. Always generate your own safe filename:

// Dangerous — using original filename directly
filename: (req, file, cb) => {
  cb(null, file.originalname); // could be "../../config.js"
}

// Safe — generate a clean filename
const crypto = require("crypto");
const path = require("path");

filename: (req, file, cb) => {
  const ext = path.extname(file.originalname).toLowerCase(); // .jpg, .png
  const safeName = crypto.randomUUID() + ext;               // random UUID + extension
  cb(null, safeName);
}

The UUID is random and unique. The extension is extracted safely with path.extname and lowercased. The user's original filename has no influence on where the file is stored.

4. Don't Serve Uploads Through Your App Root

If you accidentally configure static serving at the root, your entire project folder becomes publicly browsable. Always point express.static specifically at your uploads folder, never at . or __dirname:

// Dangerous — serves everything in your project
app.use(express.static("."));
app.use(express.static(__dirname));

// Safe — only serves files from uploads/
app.use("/files", express.static("uploads"));

For an extra layer of safety, store uploaded files in a folder that's completely outside your application code. This makes it physically impossible for a path traversal attack to reach your server files:

/home/app/server/           ← your code lives here
/home/app/uploads/          ← uploaded files live here (separate)
const UPLOAD_DIR = "/home/app/uploads"; // absolute path, outside project

const storage = multer.diskStorage({
  destination: UPLOAD_DIR,
  filename: (req, file, cb) => {
    const safeName = crypto.randomUUID() + path.extname(file.originalname).toLowerCase();
    cb(null, safeName);
  }
});

Putting It All Together

Here's a complete, safe file upload and serving setup:

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

const app = express();

// Static file serving
app.use("/files", express.static("uploads"));

// Safe Multer configuration
const storage = multer.diskStorage({
  destination: "uploads/",
  filename: function(req, file, callback) {
    const ext = path.extname(file.originalname).toLowerCase();
    const safeName = crypto.randomUUID() + ext;
    callback(null, safeName);
  }
});

const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
  fileFilter: function(req, file, callback) {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (!allowed.includes(file.mimetype)) {
      return callback(new Error("Only JPEG, PNG, and WebP images are allowed"), false);
    }
    callback(null, true);
  }
});

// Upload route
app.post("/upload", upload.single("photo"), function(req, res) {
  if (!req.file) {
    return res.status(400).json({ error: "No file uploaded" });
  }

  const fileUrl = `http://localhost:3000/files/${req.file.filename}`;
  res.json({ url: fileUrl });
});

// Error handler for Multer errors
app.use(function(err, req, res, next) {
  if (err instanceof multer.MulterError) {
    if (err.code === "LIMIT_FILE_SIZE") {
      return res.status(400).json({ error: "File exceeds 5MB limit" });
    }
    return res.status(400).json({ error: err.message });
  }
  if (err) {
    return res.status(400).json({ error: err.message });
  }
  next();
});

app.listen(3000, () => console.log("Listening on port 3000"));

Key Takeaways

  • Multer handles file uploads in Express, it receives multipart form data and saves files to disk or cloud.

  • Local storage is simple and works on a single server. External storage (S3 etc.) is required for multi-server setups.

  • express.static("uploads") maps a URL path to a local folder and serves files automatically.

  • Store the file URL (not the file itself) in your database alongside the record it belongs to.

  • Never use the original filename, generate a safe random name using crypto.randomUUID().

  • Validate file types using MIME type filtering. For higher security, inspect the actual file content.

  • Set file size limits to prevent storage exhaustion.

  • Never point express.static at your project root, only at the specific uploads folder.


Quick Reference

// Setup
npm install multer

// Basic disk storage
const storage = multer.diskStorage({
  destination: "uploads/",
  filename: (req, file, cb) => {
    const safeName = crypto.randomUUID() + path.extname(file.originalname).toLowerCase();
    cb(null, safeName);
  }
});

// With limits and file type filter
const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
  fileFilter: (req, file, cb) => {
    const ok = ["image/jpeg", "image/png"].includes(file.mimetype);
    cb(ok ? null : new Error("Invalid type"), ok);
  }
});

// Route
app.post("/upload", upload.single("fieldName"), (req, res) => {
  const url = `/files/${req.file.filename}`;
  res.json({ url });
});

// Serve files
app.use("/files", express.static("uploads"));

// Handle Multer errors
app.use((err, req, res, next) => {
  if (err instanceof multer.MulterError || err) {
    res.status(400).json({ error: err.message });
  }
});

Storing a file is easy. Storing it safely, naming it correctly, serving it cleanly, and handling errors gracefully, that's the part that takes thought.