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
nodefrom — not the file. The safe form isexpress.static(path.join(import.meta.dirname, "public"))(or__dirnamein 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
})
);
| Option | Default | Description |
|---|---|---|
maxAge | 0 | Cache-Control max-age, in ms or a string like "1d", "30d" |
immutable | false | Adds 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 |
etag | true | Send an ETag for conditional requests |
lastModified | true | Send a Last-Modified header |
fallthrough | true | On missing file, call next() instead of responding 404 |
redirect | true | Redirect directory paths to a trailing slash |
Pair long
maxAgevalues 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. Useimmutableonly 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
/staticor/assetsto avoid clashes with application routes. - Set a long
maxAge(withimmutable) for content-hashed bundles, and a short or zeromaxAgefor 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.envare never exposed, and never pointexpress.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.