Skip to content
Express.js ex files 5 min read

Serving Static Files

Almost every web app needs to ship files the browser asks for directly: stylesheets, images, fonts, favicons, and bundled client-side JavaScript. Rather than writing a route for each one, Express gives you express.static() — a built-in middleware that maps a directory on disk to URLs and streams matching files automatically. Get it right and your assets are served with proper caching, correct MIME types, and zero boilerplate; get it wrong and you’ll leak source files or serve stale CSS for a week.

The express.static middleware

express.static() is a middleware factory: you call it with a directory and it returns a middleware that, for each request, checks whether a matching file exists in that directory. If one does, it streams the file with the right Content-Type, Content-Length, and Last-Modified headers and ends the response. If not, it calls next() so your routes can handle the request.

import express from "express";

const app = express();

app.use(express.static("public"));

app.listen(3000);

Given a project layout like this:

project/
├── public/
│   ├── index.html
│   ├── css/site.css
│   └── img/logo.png
└── server.js

the files become reachable by their path relative to public — note that the directory name itself is not part of the URL:

curl -I http://localhost:3000/css/site.css

Output:

HTTP/1.1 200 OK
Content-Type: text/css; charset=UTF-8
Content-Length: 1842
Cache-Control: public, max-age=0
Last-Modified: Sat, 14 Jun 2026 09:12:44 GMT
ETag: W/"732-1976f4c..."

Use an absolute path in production. A relative path is resolved against the process’s current working directory — wherever you launched node from — not the file. The safe form is express.static(path.join(import.meta.dirname, "public")) (or __dirname in CommonJS).

Multiple static directories

You can mount express.static() more than once. Express tries each directory in the order it was registered and serves the first match, which is handy when assets are split across folders (for example a committed public/ plus a generated dist/).

import express from "express";
import path from "node:path";

const app = express();
const root = import.meta.dirname;

app.use(express.static(path.join(root, "public")));
app.use(express.static(path.join(root, "dist")));

A request for /app.js is first looked up in public; if it isn’t there, Express falls through to dist. Order matters: the first directory containing the file wins, so put your highest-priority source first.

Virtual path prefixes

Serving everything at the root URL can collide with your application routes. Mount the middleware under a path prefix to namespace all assets behind it. The prefix is virtual — it exists only in the URL and never appears on disk.

app.use("/static", express.static(path.join(root, "public")));

Now public/img/logo.png is served at /static/img/logo.png, leaving /, /api, and friends free for routing. This is the recommended setup for any non-trivial app.

curl -I http://localhost:3000/static/img/logo.png

Output:

HTTP/1.1 200 OK
Content-Type: image/png
Cache-Control: public, max-age=0

Caching and other options

The second argument to express.static() is an options object (passed through to the underlying serve-static and send libraries). The most impactful one is maxAge, which sets the Cache-Control: max-age header so browsers and CDNs can cache assets instead of re-downloading them on every page load.

app.use(
  "/static",
  express.static(path.join(root, "public"), {
    maxAge: "30d",        // cache for 30 days
    immutable: true,      // tell browsers the file never changes
    index: "index.html",  // file served for directory requests
    dotfiles: "ignore",   // 404 hidden files like .env
    etag: true,           // enable ETag validation
    lastModified: true,
    fallthrough: true,    // call next() on 404 instead of erroring
  })
);
OptionDefaultDescription
maxAge0Cache-Control max-age, in ms or a string like "1d", "30d"
immutablefalseAdds immutable to Cache-Control so clients skip revalidation
index"index.html"File served when a directory is requested; false disables it
dotfiles"ignore""allow", "deny", or "ignore" hidden files
etagtrueSend an ETag for conditional requests
lastModifiedtrueSend a Last-Modified header
fallthroughtrueOn missing file, call next() instead of responding 404
redirecttrueRedirect directory paths to a trailing slash

Pair long maxAge values with hashed filenames (e.g. site.4f3a9c.css). The hash changes whenever the content changes, so the URL changes too and the stale-cache problem disappears. Use immutable only on such fingerprinted assets, never on files whose names stay constant.

A short maxAge of 0 (the default) still benefits from ETag/Last-Modified: the browser sends a conditional request and the server answers 304 Not Modified with an empty body when nothing changed.

A complete example

import express from "express";
import path from "node:path";

const app = express();
const root = import.meta.dirname;

// Long-lived, fingerprinted bundles
app.use(
  "/assets",
  express.static(path.join(root, "dist"), { maxAge: "1y", immutable: true })
);

// General public files, revalidated each load
app.use(express.static(path.join(root, "public"), { maxAge: 0 }));

app.get("/api/health", (req, res) => {
  res.json({ status: "ok" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));

This serves fingerprinted bundles aggressively cached under /assets, everyday files from public at the root, and keeps your JSON API on its own path.

Best Practices

  • Always pass an absolute path (path.join(import.meta.dirname, ...)) so file resolution does not depend on the working directory.
  • Mount assets under a virtual prefix like /static or /assets to avoid clashes with application routes.
  • Set a long maxAge (with immutable) for content-hashed bundles, and a short or zero maxAge for files served under stable names.
  • Register static middleware before your routers so asset requests short-circuit early and never hit route logic.
  • Keep dotfiles: "ignore" (or "deny") so secrets like .env are never exposed, and never point express.static() at a directory containing source code.
  • In production, consider offloading static delivery to a CDN or reverse proxy (Nginx); express.static() is excellent for development and small apps but a dedicated layer scales better.
Last updated June 14, 2026
Was this helpful?