Home / Articles

From Upload to URL: Storing and Serving User Files Safely in Express

Learn where Express apps should keep uploaded files, how express.static maps a folder to URLs, and which safeguards keep user uploads from becoming a security hole.

Publisher: REACTAPP.TOP7 min read1589 words

This article is published in English.

Sources: Storing Uploaded Files and Serving Them in Express

A user chooses a profile photo, clicks upload, and a second later it appears on their page. Behind that small interaction sit three real design decisions: where the file physically lives, how the server locates it again, and how a browser ends up displaying it. This guide works through all three for an Express application, from choosing a storage location to exposing files over a URL, and closes with the precautions that keep uploads from turning into an attack surface.

Giving uploads a home on disk

Before a file can be served again, it has to be written somewhere. In the simplest setup that is a folder on the server's own file system, commonly named uploads and placed at the project root next to the source code, not inside it:

project-root/
  uploads/
    avatars/
      user-42.png
      user-91.png
    documents/
      invoice-108.pdf
  src/
    app.js

Grouping files by purpose, such as avatars/ and documents/, pays off quickly. It keeps directory listings manageable as the count grows, makes cleanup and backup policies easier to apply per category, and, as you will see shortly, produces URLs that are easy to predict. A single flat folder holding every kind of file becomes hard to reason about long before it becomes a performance problem.

In most applications the database stores only a reference to the file, such as the relative path avatars/user-42.png, not the file itself and not a full URL. Storing the relative path means you can later change the domain or move to a different storage backend without rewriting every row.

Local disk or external object storage

Keeping files on the application server

Writing files straight to the server's disk, as in the layout above, needs no extra infrastructure. It is a good fit for local development, prototypes, internal tools and applications whose upload volume is small and predictable.

Moving files to a storage service

Production systems more often hand uploads to a dedicated object store, for example Amazon S3, Google Cloud Storage or a comparable service, and keep the application server's disk out of the picture.

Why the choice matters

  • Disks can be small and short-lived. Many hosting platforms rebuild or replace instances on every deploy, so a file that exists only on local disk may vanish the next time you ship.
  • Multiple instances do not share a disk. Once you scale horizontally, a file saved on one instance is invisible to the others. A central store gives every instance the same view.
  • Object stores are built for this job. Durability, replication, backups and efficient delivery at scale are their core purpose, while a general-purpose app server is optimized for running your code.

None of this makes local storage wrong. For learning projects and genuinely small applications it is a sensible first step, and the ideas below (organized paths, predictable URLs, careful validation) carry over unchanged if you migrate to external storage later. Keeping the storage code behind a small module with functions like save and getUrl makes that migration much less painful.

Letting express.static serve the folder

With the file saved, the browser still needs a way to fetch it. Express ships with a static file middleware for exactly that purpose:

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

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

This line mounts the uploads directory under the /uploads URL prefix. For any request whose path starts with that prefix, Express looks for a file at the matching location inside the folder and, if it exists, streams it back with an appropriate content type and caching headers. You do not write a handler per file.

That is a different mental model from an ordinary API route. Instead of running logic that loads data and builds a response, you are publishing an entire directory, matched purely by path. Two practical notes follow from that:

  • The folder argument is resolved relative to the directory the Node process was started from, not the file that contains the code. Using an absolute path, such as one built with path.join(__dirname, "..", "uploads"), avoids surprises when the app is launched from a different working directory.
  • Anything placed in that folder becomes public. Only mount the directory that is meant to be exposed.

How a saved file becomes a URL

Once static serving is set up, the path on disk and the path in the URL mirror each other:

File on disk:     uploads/avatars/user-42.png
Accessible at:    https://yourapp.com/uploads/avatars/user-42.png

Because of that one-to-one mapping, the frontend can point straight at the file, for example in an image tag, without any dedicated API endpoint for downloading bytes:

<img src="https://yourapp.com/uploads/avatars/user-42.png" alt="User avatar" />

This is where the folder structure from the first section earns its keep. Whatever layout you choose on disk is exactly the layout clients see in URLs, so a tidy, consistent structure gives you tidy, consistent links. It also means the URL can be built from the relative path stored in the database, which keeps the domain and prefix in one configuration value.

Security precautions for user uploads

Uploads are a favorite target for abuse, so "the upload works" is only the starting point. Each of the following closes a specific hole.

Check what the file really is

A filename extension is just text chosen by the client and proves nothing. Inspect the actual content type, ideally by reading the file's leading bytes rather than trusting the declared MIME type, and accept only what the feature needs. An avatar endpoint, for instance, should take images and nothing else. Formats such as SVG and HTML deserve extra caution because browsers can execute scripts embedded in them.

Cap the size

Set a maximum size for each upload. Without a limit, a single oversized request, accidental or deliberate, can fill the disk or saturate bandwidth. Enforce the limit while parsing the request, so the server stops reading early instead of accepting the whole body first.

Generate your own filenames

Never write a file under the name the client supplied. That name can contain odd characters or path traversal sequences like ../../config.js intended to escape the upload directory. Create a fresh name on the server, such as a random identifier plus a verified extension. As a side effect, this also prevents two users who both upload photo.jpg from overwriting each other.

Keep the upload folder isolated

Store uploads in a directory clearly separated from source code and configuration, and make sure the static middleware exposes only that directory. Mounting the project root by mistake would publish environment files and source code to anyone who guesses a path.

Think about which origin serves the files

Serving user-supplied content from the same domain as the main application can open the door to issues like cross-site scripting, because a malicious file would run with your site's origin. For that reason many production systems deliver uploads from external storage or a separate subdomain. Sending an X-Content-Type-Options: nosniff header and, for downloads that should not render inline, a Content-Disposition: attachment header further reduce the risk.

Common questions

Should every project use S3 or a similar service?

Not necessarily. For small or learning projects, local disk is simpler and entirely adequate. External storage becomes important once you deploy to a platform with an ephemeral file system or run several instances that must see the same files.

Does express.static need an extra package?

No, it is built into Express. Parsing the incoming upload from a multipart form usually does require a library such as Multer, but serving files that are already saved does not.

Is it acceptable to keep the original filename?

It is safer not to. A server-generated name avoids strange characters, collisions between identical names, and traversal tricks hidden in a crafted filename. If users need to see the original name, store it as metadata in the database instead.

Can access to uploaded files be restricted?

Yes, but not with express.static alone, which hands a file to anyone who knows its URL. Private files call for a dedicated route that checks authentication and authorization before streaming the file, or, with external storage, short-lived links generated only for permitted users.

Wrapping up

A solid upload feature answers four questions in order. Where does the file live: on the server's disk because it is simple, or in an object store because it survives deploys and scales? How does the app locate it later: via an organized folder layout and a relative path kept in the database? How will a browser fetch it: through express.static mapped to a clear URL prefix, or a guarded route for private files? And what keeps it safe: verified types, size limits, server-generated names and a narrowly scoped, ideally separate, serving location. Settle those, and the rest of the feature is mostly plumbing.