Serving Static Assets

express.static is the serve-static package (2.2.1, MIT) re-exported under the Express 24,430 namespace, so it needs no install. Give it a directory; the URL path after the mount point becomes the path inside that directory.

Two static roots on one appJavaScript
app.use(express.static(path.join(__dirname, 'public')));
app.use('/assets', express.static(path.join(__dirname, 'build'), {
  immutable: true, maxAge: '1y', index: false, dotfiles: 'ignore'
}));
app.get('/', (req, res) => res.send('root'));

The first mount is unprefixed, so public/css/site.css is served at /css/site.css; the second is prefixed, so build/app.4f1c9d2e.js is served at /assets/app.4f1c9d2e.js. Build paths with path.join and __dirname: a relative string such as 'public' resolves against the process working directory, whatever folder the operator happened to be in. The response headers show how much the middleware does.

Output of 26
$ curl -s -D - -o /dev/null http://127.0.0.1:4187/css/site.css
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Thu, 17 Sep 2026 17:18:03 GMT
ETag: W/"1f-1a0b0601b6e"
Content-Type: text/css; charset=utf-8

Content type from the extension, a weak ETag and Last-Modified from size and mtime, byte ranges, and a Cache-Control that permits caching but forces revalidation. Files are streamed, so a 2 GB video costs the same memory as a favicon.

The other options worth knowing are index (the file used for a directory URL), dotfiles (allow, deny or ignore for .env and friends), extensions (try ['html'] so /about finds about.html) and setHeaders, a per-file header hook used in the next subsection.

Because fallthrough defaults to true, a mount that finds nothing passes the request on, so you can stack roots and end with your routes. A mount that does find something wins outright: above, GET / returns public/index.html and the app.get('/') handler never runs. Traversal is handled for you: /../package.json and its percent-encoded form, sent with --path-as-is, both returned 404.