Serving Static Files
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the
official documentation on 2026-10-29. Static files (images, stylesheets, scripts, fonts, downloads, a built
single-page application) are served in Express by serve-static, a small package of the expressjs organization that
Express re-exports as express.static; the package in turn delegates the real work (path resolution, conditional
requests, ranges, content types, ETags) to send. Express 5.3 depends on serve-static 2.2 and send 1.x. The
examples belong to the Bookshelf project that grows across this section, use ESM and the node: prefix for built-in
modules, and contain placeholders only, never real secrets.
How static serving works
express.static(root, options) returns an ordinary middleware function, so everything in
Middleware Fundamentals applies: it runs in registration order, it
can be mounted on a path, and it either answers the request or calls next().
For every request, the middleware does the following:
-
If the method is neither
GETnorHEAD, it callsnext()(or answers405whenfallthroughisfalse). -
It removes the mount path from the URL, decodes the remaining path and joins it to
root. Paths that would leaveroot(through..segments or encoded variants) and paths containing a null byte are refused bysend. -
It looks for a file. If the path is a directory it serves the
indexfile, or redirects to the version with a trailing slash (theredirectoption). -
It answers conditional requests:
If-None-MatchandIf-Modified-Sinceproduce304 Not Modified,If-RangeandRangeproduce206 Partial Contentor416. -
It streams the file with
Content-Typetaken from the file extension,Content-Length,Last-Modified, a weakETagandCache-Control. -
If nothing was found,
fallthrough: true(the default) callsnext()so that later layers, ultimately your404handler, can answer.
Because the middleware reads the file system on every request and sets validators, the cost per request is small, but
it is not zero: every hit costs a stat call at least. For high-traffic assets a CDN or reverse proxy in front of
Express is the better place to cache, as described in When a CDN or reverse proxy should serve the files.
A first complete example
The following program serves a public folder at the site root and an assets folder under the virtual prefix
/static. Create the files shown in the tree, run npm install express and then node server.mjs.
bookshelf-web/
package.json ("type": "module")
server.mjs
public/
index.html
css/site.css
js/app.js
covers/dune.svg
assets/
logo.svg
// server.mjs
import { join } from 'node:path';
import express from 'express';
const app = express();
// import.meta.dirname is available from Node.js 20.11 and 21.2.
// On Node.js 18 use fileURLToPath(new URL('.', import.meta.url)) instead.
const publicDir = join(import.meta.dirname, 'public');
const assetsDir = join(import.meta.dirname, 'assets');
// Served at the root: GET /css/site.css, GET /covers/dune.svg, GET / (index.html).
app.use(express.static(publicDir));
// Served under a virtual prefix that does not exist on disk: GET /static/logo.svg.
app.use('/static', express.static(assetsDir));
app.get('/api/v1/health', (req, res) => {
res.json({ status: 'ok' });
});
// Nothing matched: static files fell through to here.
app.use((req, res) => {
res.status(404).json({ title: 'Not Found', status: 404 });
});
app.listen(3000, (error) => {
if (error) {
throw error; // Express 5 passes listen errors (such as EADDRINUSE) to the callback
}
console.log('Bookshelf listening on http://localhost:3000');
});
A request for /css/site.css is answered by the first express.static(). A request for /static/logo.svg fails to
find public/static/logo.svg, falls through and is answered by the second. A request for /missing.png falls through
both and reaches the 404 layer. The URL path never contains the name of the root folder: public is not part of the
address, which is also why a folder that should stay private must never be passed as the root.
Roots, absolute paths and ESM
root is resolved against the current working directory of the process, not against the file that calls
express.static(). A relative root such as express.static('public') therefore works when the application is started
from the project folder and fails when it is started from anywhere else (a service manager, a container with a
different WORKDIR, a test runner). Always build an absolute path.
| Module system | Way to get the folder of the current file |
|---|---|
ESM, Node.js 20.11 or later |
|
ESM, Node.js 18 and 20.0 to 20.10 |
|
CommonJS |
|
Prefer path.join() or path.resolve() over string concatenation, so that separators are correct on Windows and a
missing slash cannot merge two names. The first argument of express.static() is not a URL pattern: wildcards, named
parameters and regular expressions do nothing there. Patterns belong to the optional mount path of app.use(), which
follows the path-to-regexp 8 rules described in Route Paths & Parameters.
Every express.static option
express.static(root, options) accepts the following options. They are the options of serve-static 2.x; most of
them are handed to send.
| Option | Default | Meaning |
|---|---|---|
|
|
How to treat files and directories whose name starts with a dot. |
|
|
Generates a weak |
|
|
A list such as |
|
|
|
|
|
Adds the |
|
|
The file served for a directory. A string, an array of names tried in order, or |
|
|
Sends |
|
|
The |
|
|
When the path is a directory and the URL has no trailing slash, answers a redirect to the URL with a slash, so that
relative links in |
|
none |
A function |
|
|
Honours the |
|
|
Sends |
Two options deserve a note because they change behaviour in ways that surprise people. extensions makes URLs without
an extension work, but the same resource is then reachable under two URLs (/about and /about.html), which matters for
caching and for search engines; choose one canonical form and redirect the other if it matters. index: false combined
with the default redirect: true still redirects a directory URL to the slashed form before falling through, which is
easy to mistake for a bug when a router is expected to handle the directory path.
Dotfiles, /.well-known and the Express 5 default
Hidden files are the most frequent source of accidents with static serving: .env, .git, .htpasswd, .npmrc,
editor backups and OS metadata have all ended up in public folders and been served to the internet. serve-static 2.x
(and so Express 5) defaults to dotfiles: 'ignore', which treats every path segment starting with a dot as absent.
This default has a legitimate casualty. Several standards use a dot directory under the site root and require it to be
public: the /.well-known/ URI space of RFC 8615 holds acme-challenge tokens for Let’s Encrypt,
security.txt, apple-app-site-association, assetlinks.json for Android app links, WebFinger and OpenID
configuration. In Express 4, files below a dot directory in the public folder were served by default; in Express 5 a
request for /.well-known/security.txt answers 404. The official migration guide lists this change, and its codemod
static-dotfiles rewrites calls that relied on the old behaviour.
The safest fix is to leave the global default alone and allow dotfiles on one dedicated mount:
// well-known.mjs
import { join } from 'node:path';
import express from 'express';
const app = express();
const publicDir = join(import.meta.dirname, 'public');
const wellKnownDir = join(import.meta.dirname, 'well-known');
// Only this directory may contain dotfiles to serve, and only under this exact prefix.
// The folder holds acme-challenge/, security.txt and similar files and nothing else.
app.use(
'/.well-known',
express.static(wellKnownDir, {
dotfiles: 'allow',
fallthrough: false, // a missing token is a plain 404 produced by the error handler below
index: false,
maxAge: '5m',
}),
);
// Everything else keeps the safe default: dotfiles are ignored.
app.use(express.static(publicDir, { dotfiles: 'ignore' }));
// Optional hardening: answer 403 instead of 404 for hidden files so that probes are noticeable in logs.
// app.use(express.static(publicDir, { dotfiles: 'deny' }));
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}
const status = error.status ?? 500;
res.status(status).json({ title: status === 404 ? 'Not Found' : 'Error', status });
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
The mount path matters: send checks dotfiles on the path below the root, so the mount prefix /.well-known (which is
stripped before send sees the URL) does not count against the setting, and the folder well-known on disk does not
need a dot at all. Putting the files in a differently named folder is also better practice than a real .well-known
directory inside public, which would invite other dot directories to be added next to it.
The three values compare as follows:
| Value | Request for /.env when the file exists |
With fallthrough: true |
With fallthrough: false |
|---|---|---|---|
|
Looks absent. |
|
|
|
Recognised, refused. |
|
|
|
Served. |
Served. |
Served. |
Use 'allow' only on a folder that contains nothing you would be unhappy to see published. Hiding dotfiles is a
safety net, not a substitute for keeping secrets out of the served folder in the first place; the folder passed to
express.static() should contain build output and public assets only.
Several roots and virtual path prefixes
express.static() can be called any number of times. Each call is its own layer, so Express asks them in registration
order and the first one that finds the file answers:
// roots.mjs
import { join } from 'node:path';
import express from 'express';
const app = express();
const here = import.meta.dirname;
// 1. Overrides first: a theme folder can replace individual files of the base folder.
app.use(express.static(join(here, 'theme-overrides')));
// 2. The base site. A file missing from theme-overrides is found here.
app.use(express.static(join(here, 'public')));
// 3. Optional pages: /pricing serves public-optional/pricing.html.
app.use(express.static(join(here, 'public-optional'), { extensions: ['html', 'htm'] }));
// 4. A virtual prefix: /static/logo.svg is read from assets/logo.svg.
// No static folder exists on disk. req.url inside the layer is /logo.svg; req.baseUrl is /static.
app.use('/static', express.static(join(here, 'assets')));
// 5. A prefix with a parameter-free pattern and its own caching policy.
app.use('/covers', express.static(join(here, 'covers'), { maxAge: '7d' }));
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Points to keep in mind:
-
The prefix is removed before the file is looked up, so it never needs to exist as a folder and can differ from the folder name. A virtual prefix such as
/staticalso makes it trivial to see in logs and proxy rules which requests are static assets, and to exclude them from session middleware, authentication and rate limiting that you register later (static files registered before the session layer do not create sessions; see Cookies & Sessions). -
Each additional root costs one failed
statfor requests that only the last root can answer. Keep the list short, or put the most frequently hit root first. -
The same applies when the static layers sit below a router:
router.use('/static', express.static(dir))mounts it at the router’s own prefix, so the public URL is the router’s mount path plus/static(see Routers & Modular Apps). -
A static layer registered after catch-all routes is never reached for paths those routes answer. Register static middleware before the API routes unless a route must win over a file of the same name.
-
Only
GETandHEADare served.POST /css/site.cssfalls through (or answers405withfallthrough: false).
Caching, ETags and cache-busting
With default options a file response carries Cache-Control: public, max-age=0, a weak ETag and Last-Modified.
Browsers store the file but ask the server again on every use; unchanged files answer 304 Not Modified without a body.
This is correct and safe, but it costs a round trip for every asset on every page view. The usual improvement is to give
immutable, content-addressed assets a very long lifetime and keep the HTML short-lived, so that a new deployment is
picked up at once.
// caching.mjs
import { extname, join } from 'node:path';
import express from 'express';
const app = express();
const publicDir = join(import.meta.dirname, 'public');
// A build tool (Vite, esbuild, webpack) writes names such as app.3f9a1c7e.js and site.b21e0d44.css.
const fingerprinted = /\.[0-9a-f]{8,}\.(?:js|css|woff2|svg|png|jpg|webp|avif)$/;
app.use(
express.static(publicDir, {
// Applies to files that setHeaders does not override: revalidate every time.
maxAge: 0,
setHeaders(res, filePath) {
if (fingerprinted.test(filePath)) {
// The content is part of the name, so the file can never change: one year, no revalidation.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else if (extname(filePath) === '.html') {
// HTML refers to the current fingerprints, so it must always be revalidated.
res.setHeader('Cache-Control', 'no-cache');
}
},
}),
);
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Equivalent configuration without a function is possible when the whole folder is fingerprinted:
express.static(dir, { maxAge: '1y', immutable: true }). That is wrong for a folder that mixes fingerprinted assets
with index.html or unversioned files, because browsers would keep an old index.html for a year.
Rules that avoid the usual mistakes:
-
immutableonly makes sense for URLs that change when the content changes. If a file keeps its name, give it a shortmaxAgeor none. -
no-cachedoes not mean "do not store"; it means "store, but revalidate before use". Useno-storeonly for data that must never be kept. -
setHeadersis called aftersendhas prepared its own headers, so a value set there replacesCache-ControlfrommaxAge. It receives the absolute path of the file, not the URL. -
senddoes not overwrite aContent-Typethat is already set, which is how an unusual extension is given a specific type now thatexpress.static.mimeno longer exists (see Where the books are outdated). -
Conditional requests, ranges and validators are explained in Content Negotiation & HTTP Caching.
What the responses look like
With the default options, a first request, a revalidation and a request for a dotfile look like this:
GET /css/site.css HTTP/1.1
Host: localhost:3000
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Wed, 28 Oct 2026 09:14:02 GMT
ETag: W/"1a4-19a2c0e5f20"
Content-Type: text/css; charset=utf-8
Content-Length: 420
GET /css/site.css HTTP/1.1
Host: localhost:3000
If-None-Match: W/"1a4-19a2c0e5f20"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=0
ETag: W/"1a4-19a2c0e5f20"
GET /.env HTTP/1.1
Host: localhost:3000
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Source: Express middleware: serve-static
serve-favicon
Browsers request /favicon.ico on their own, on every site, usually before anything else and again for each new tab.
Letting those requests reach the logger, the session layer and the router wastes work and fills logs. The serve-favicon
module (2.5.x, maintained by the Express team) answers them from memory:
// favicon.mjs
import { join } from 'node:path';
import express from 'express';
import favicon from 'serve-favicon';
const app = express();
// Register it first, before the logger and anything that creates sessions.
// The file is read once at startup and kept in memory; changes need a restart.
app.use(
favicon(join(import.meta.dirname, 'public', 'favicon.ico'), {
maxAge: '30d', // default is one year
}),
);
app.use(express.static(join(import.meta.dirname, 'public')));
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
The module only answers GET, HEAD and OPTIONS for the exact path /favicon.ico; it accepts a path or a Buffer,
sets Content-Type: image/x-icon, a strong ETag and a Cache-Control from the option maxAge (milliseconds or an
ms string; the default is one year), and answers 304 for matching validators. Modern sites also advertise PNG or SVG
icons with <link rel="icon"> elements in the page, and those are ordinary files served by express.static(); the
middleware is only worth adding for the legacy /favicon.ico request. If a stylesheet-based icon is enough, an empty
204 route for /favicon.ico is the cheapest alternative.
serve-index
serve-index renders a directory listing, like the directory index of Apache or Nginx. It is a development and
file-sharing tool, not a default for a public site: a listing enumerates every file name, including backups and
exports that nobody linked to.
// files.mjs
import { join } from 'node:path';
import express from 'express';
import serveIndex from 'serve-index';
const app = express();
const exportsDir = join(import.meta.dirname, 'exports');
// The listing needs the files to be served too, so the two layers share the folder.
app.use(
'/exports',
express.static(exportsDir, { index: false, dotfiles: 'ignore' }),
serveIndex(exportsDir, {
icons: true, // file-type icons next to the names
view: 'details', // 'tiles' (default) or 'details' (size and date columns)
hidden: false, // do not list dotfiles (default)
filter: (filename) => filename.endsWith('.csv'), // list CSV exports only
}),
);
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Source: Express middleware: serve-index
serve-index (1.9.x) negotiates the format with the Accept header: HTML for browsers, JSON for
Accept: application/json, plain text for text/plain. Its options are filter (a function (filename, index, files,
dir) deciding which entries appear), hidden (list dotfiles, default false), icons (default false), stylesheet
(a path to replace the built-in CSS), template (a path or function to replace the HTML) and view (tiles or
details). It never lists a directory that falls outside root and answers 403 for forbidden paths, but it does not
add authentication: protect the mount with your own middleware if the files are not public.
serve-static without Express
express.static is serve-static re-exported, and the package works with any Node.js server. This is useful for a tiny
preview server, a script, or a test that must serve files without pulling in the framework. Depend on serve-static
directly only in that case; inside an Express application import nothing and use express.static, which avoids an
undeclared or mismatched dependency.
// preview.mjs
import { createServer } from 'node:http';
import { join } from 'node:path';
import finalhandler from 'finalhandler';
import serveStatic from 'serve-static';
const serve = serveStatic(join(import.meta.dirname, 'dist'), {
index: ['index.html'],
maxAge: '1h',
});
const server = createServer((req, res) => {
// finalhandler produces the 404, 403 or 405 response when serve-static calls next.
serve(req, res, finalhandler(req, res));
});
server.listen(3000, () => {
console.log('Previewing dist/ on http://localhost:3000');
});
Source: Express middleware: serve-static
res.sendFile and res.download
express.static() publishes a folder. When a single route must send one file, perhaps after an authorisation check or
a database lookup, use res.sendFile(), which is the same send machinery for one path, or res.download(), which
adds a Content-Disposition: attachment header so that browsers save the file instead of showing it.
res.sendFile(path, options, callback) option |
Meaning |
|---|---|
|
A base directory. When it is given, |
|
|
|
|
|
Adds |
|
|
|
Sends |
|
Honours |
|
An object of extra headers set on the response. |
Express 5 removed the lower-case res.sendfile() and the hidden and from options; the replacements are
res.sendFile() and dotfiles. The callback is called when the transfer finishes or fails. Once a callback is given,
Express no longer finishes the response on error, so the callback must do it: check res.headersSent, because after a
partial transfer the only option left is to let the connection close, and call next(err) otherwise.
// downloads.mjs
import { join } from 'node:path';
import express from 'express';
const app = express();
const filesDir = join(import.meta.dirname, 'files');
// Inline display, for example a generated PDF invoice or a cover image checked by the application.
app.get('/books/:id/cover', (req, res, next) => {
// A real application would look the id up in the database and derive the file name itself.
if (!/^[a-z0-9-]+$/.test(req.params.id)) {
return res.status(400).json({ title: 'Bad Request', status: 400 });
}
res.sendFile(
`${req.params.id}.svg`,
{ root: join(import.meta.dirname, 'covers'), maxAge: '1d', dotfiles: 'deny' },
(error) => {
if (error) {
next(error.code === 'ENOENT' ? undefined : error); // ENOENT: let the 404 layer answer
}
},
);
});
// Download with a chosen file name. The wildcard value is an array in Express 5.
app.get('/files/*path', (req, res, next) => {
const relative = req.params.path.join('/');
res.download(relative, `bookshelf-${req.params.path.at(-1)}`, { root: filesDir, dotfiles: 'deny' }, (error) => {
if (error && !res.headersSent) {
next(error.code === 'ENOENT' ? undefined : error);
}
});
});
app.use((req, res) => {
res.status(404).json({ title: 'Not Found', status: 404 });
});
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}
res.status(error.status ?? 500).json({ title: 'Error', status: error.status ?? 500 });
});
app.listen(3000, (error) => {
if (error) {
throw error;
}
});
Why root is not optional in practice: passing a path built from req.params or req.query without root is a
path-traversal vulnerability, because the request controls where the file is read. With root, send rejects ..
segments and null bytes before touching the disk; without it, the application is on its own. Never concatenate user
input into a path and hand it to res.sendFile() unaided.
Differences from express.static() worth remembering:
-
res.sendFile()answers a single, predefined path;express.static()maps the URL to a path. -
A missing file is reported through the callback (error code
ENOENT), not by falling through, so the route decides what to do. -
Headers such as
Content-Dispositionfor downloads, and the bytes of aRangerequest, are the same machinery; see File Uploads & Downloads for uploads andres.attachment(), and Response for the wholeresAPI.
When a CDN or reverse proxy should serve the files
Node.js can serve static files well enough for small sites, internal tools and development, but a long-running application server is not the best place for them in production:
-
Every file read competes with API requests for the same event loop and thread pool.
-
A CDN answers from a location close to the visitor, absorbs traffic spikes and handles compression, HTTP/2 or HTTP/3 and image variants.
-
A reverse proxy such as Nginx or a cloud load balancer can serve a mounted folder directly with
sendfile, caching and compression, and forward only dynamic requests to Express. -
Many platforms deploy the static build output to object storage and a CDN on one side, and the API on the other.
The official performance guide recommends putting a reverse proxy in front of Express for static content. A sensible
path is to start with express.static() (one process, one deployment), measure, and move the assets out once
traffic or caching needs justify the extra component. The URL structure with a virtual prefix such as /static makes
that move trivial: the proxy forwards everything except /static to Express, or the CDN is configured with the prefix
as its origin path. The same page covers compression and clustering in
Performance Best Practices; proxy-related request properties are
in Behind Proxies. A single-page application served by Express, with the
history fallback route placed after express.static(), is covered in
SPAs & Server-Side Rendering.
Security checklist
-
Serve a dedicated folder (
public,dist,assets), never the project root, the home directory or a folder that holds.env,node_modules, source maps you do not want public, database files, backups or uploads that have not been validated. -
Keep the default
dotfiles: 'ignore', or use'deny', everywhere except an explicit/.well-knownmount. -
Remember that
sendfollows symbolic links, and that file names are case-sensitive on Linux and case-insensitive on default Windows and macOS file systems. Do not put links that point outside the served folder inside it. -
Do not store user uploads below a served folder with the name the client chose. Generate the file name, keep uploads outside the folder served by
express.static(), and serve them through a route that checks access, as shown in File Uploads & Downloads. -
A user-supplied HTML, SVG or script file served from your own origin runs with your origin’s privileges (cross-site scripting). Serve untrusted files from a separate domain, or force a download with
res.download()andX-Content-Type-Options: nosniff, which Helmet sets (see Security Best Practices). -
A directory listing from
serve-indexreveals names; do not mount it on a public site. -
Keep
express,serve-staticandsendup to date. Path handling in static servers has had vulnerabilities in the past, and fixes arrive through these packages, so runnpm auditand read the security releases.
What changed between Express 4 and 5
| Version | Static-file-relevant change |
|---|---|
Express 4.x |
|
Express 5.0.0 (2024-09) |
|
Express 5.1 to 5.3 |
Dependency updates; |
Migration of a project that used the removed options is described in
Migrating & What Changed; the codemods static-dotfiles,
static-mime, camelcase-sendfile and sendfile-options perform the mechanical parts.
Where the books are outdated
The four books consulted for this section target Express 4.0 to 4.18. Their static-file chapters show the concept correctly, but several practices changed:
| Older practice | Where it appears | Current behaviour |
|---|---|---|
Building the root by string concatenation with |
Monteiro 2014, Sharma |
Use |
Serving client libraries from a folder installed by Bower or copied into |
Monteiro 2014, Monteiro 2016 |
Bower is deprecated. Install packages with npm and bundle them with Vite, esbuild or webpack, and serve the build
output; fingerprinted names allow |
Importing |
Bugl |
Use |
Assuming files under |
Older Express 4 tutorials |
Express 5 ignores dotfiles by default. Mount a dedicated |
Overriding file types with |
Express 4 era code |
Removed in Express 5. Set |
Storing uploads in the public folder under the original file name. |
Monteiro 2016 |
Allows path traversal and stored cross-site scripting. Generate names, validate content, store outside the served folder, and serve through a checked route. |
|
Express 3 and early 4 code |
Removed in Express 5; use |