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.
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.
$ 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.