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:

  1. If the method is neither GET nor HEAD, it calls next() (or answers 405 when fallthrough is false).

  2. It removes the mount path from the URL, decodes the remaining path and joins it to root. Paths that would leave root (through .. segments or encoded variants) and paths containing a null byte are refused by send.

  3. It looks for a file. If the path is a directory it serves the index file, or redirects to the version with a trailing slash (the redirect option).

  4. It answers conditional requests: If-None-Match and If-Modified-Since produce 304 Not Modified, If-Range and Range produce 206 Partial Content or 416.

  5. It streams the file with Content-Type taken from the file extension, Content-Length, Last-Modified, a weak ETag and Cache-Control.

  6. If nothing was found, fallthrough: true (the default) calls next() so that later layers, ultimately your 404 handler, 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

import.meta.dirname (and import.meta.filename).

ESM, Node.js 18 and 20.0 to 20.10

fileURLToPath(new URL('./public', import.meta.url)), with fileURLToPath from node:url.

CommonJS

__dirname, which is defined automatically.

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

dotfiles

'ignore'

How to treat files and directories whose name starts with a dot. 'allow' serves them like any other file, 'deny' answers 403, 'ignore' behaves as if they did not exist (404, or fall through when fallthrough is true). See Dotfiles, /.well-known and the Express 5 default.

etag

true

Generates a weak ETag from the size and modification time. Turning it off removes one of the two validators; Last-Modified still works. The application-wide etag setting does not affect this option.

extensions

false

A list such as ['html', 'htm'] tried in order when the exact name is not found, so /about can serve about.html. No leading dots. The fallback is skipped for names that already match a file or directory.

fallthrough

true

true calls next() when a file is not found and for methods other than GET and HEAD. false raises a 404 or a 403 (for a denied dotfile) through next(err) so that your error handler decides the response, and answers other methods directly with 405 and an Allow header. Use false when the static layer is the only thing that may answer under its mount path.

immutable

false

Adds the immutable directive to Cache-Control. It only has an effect when maxAge is greater than zero.

index

'index.html'

The file served for a directory. A string, an array of names tried in order, or false to disable index files (the directory then falls through or redirects).

lastModified

true

Sends Last-Modified from the file modification time and honours If-Modified-Since.

maxAge

0

The max-age of Cache-Control, in milliseconds or as a string accepted by the ms package ('1y', '30d', '2h'). The header carries seconds. See Caching, ETags and cache-busting.

redirect

true

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 index.html resolve against the directory. Set to false to fall through instead.

setHeaders

none

A function (res, path, stat) called when a file is about to be sent. It is the place to set headers per file or per type. Keep it cheap: it runs for every file response.

acceptRanges

true

Honours the Range header and sends Accept-Ranges: bytes. Media players and download managers depend on it.

cacheControl

true

Sends Cache-Control. With false, maxAge and immutable are ignored and the header is left to you, for example in setHeaders.

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

'ignore' (default)

Looks absent.

next(), so the 404 layer answers.

next(err) with a 404.

'deny'

Recognised, refused.

next(), so the 404 layer answers (the 403 is dropped).

next(err) with a 403.

'allow'

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 /static also 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 stat for 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 GET and HEAD are served. POST /css/site.css falls through (or answers 405 with fallthrough: 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:

  • immutable only makes sense for URLs that change when the content changes. If a file keeps its name, give it a short maxAge or none.

  • no-cache does not mean "do not store"; it means "store, but revalidate before use". Use no-store only for data that must never be kept.

  • setHeaders is called after send has prepared its own headers, so a value set there replaces Cache-Control from maxAge. It receives the absolute path of the file, not the URL.

  • send does not overwrite a Content-Type that is already set, which is how an unusual extension is given a specific type now that express.static.mime no 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

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;
  }
});

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');
});

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

root

A base directory. When it is given, path is relative to it and may not escape it. When it is omitted, path must be absolute, otherwise Express throws.

dotfiles

'allow', 'deny' or 'ignore', as above. Defaults to 'ignore'.

maxAge

Cache-Control max-age in milliseconds or an ms string. Default 0.

immutable

Adds immutable to Cache-Control (needs maxAge).

cacheControl

true by default; false disables the header.

lastModified

Sends Last-Modified (default true).

acceptRanges

Honours Range requests (default true).

headers

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-Disposition for downloads, and the bytes of a Range request, are the same machinery; see File Uploads & Downloads for uploads and res.attachment(), and Response for the whole res API.

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-known mount.

  • Remember that send follows 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() and X-Content-Type-Options: nosniff, which Helmet sets (see Security Best Practices).

  • A directory listing from serve-index reveals names; do not mount it on a public site.

  • Keep express, serve-static and send up to date. Path handling in static servers has had vulnerabilities in the past, and fixes arrive through these packages, so run npm audit and read the security releases.

What changed between Express 4 and 5

Version Static-file-relevant change

Express 4.x

serve-static 1.x and send 0.x. express.static.mime exists; the hidden option of express.static() and res.sendFile() and the from option were deprecated in favour of dotfiles. Files below a dot directory (such as /.well-known) were served by default.

Express 5.0.0 (2024-09)

serve-static 2.x and send 1.x. dotfiles defaults to 'ignore', hiding dot directories including /.well-known. hidden and from options removed. res.sendfile() removed in favour of res.sendFile(). express.static.mime removed. MIME changes: .js is served as text/javascript. Wildcard route parameters (used in the download route) are arrays.

Express 5.1 to 5.3

Dependency updates; serve-static 2.2 and send 1.x are current at the time of writing. Check the serve-static and send changelogs when upgrading, since fixes to path handling arrive through those packages.

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 __dirname, or a relative root such as public.

Monteiro 2014, Sharma

Use path.join() with __dirname (CommonJS) or import.meta.dirname (ESM, Node.js 20.11 or later), so that the application does not depend on the working directory.

Serving client libraries from a folder installed by Bower or copied into public/.

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 immutable caching.

Importing serve-static directly in an application that never declared it.

Bugl

Use express.static, which is the same function and comes with Express. Depend on serve-static only outside Express.

Assuming files under /.well-known (or any dot directory) are served by default.

Older Express 4 tutorials

Express 5 ignores dotfiles by default. Mount a dedicated express.static() with dotfiles: 'allow' on that path.

Overriding file types with express.static.mime.define().

Express 4 era code

Removed in Express 5. Set Content-Type in setHeaders; send keeps a type that is already 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.

res.sendfile() and the hidden option.

Express 3 and early 4 code

Removed in Express 5; use res.sendFile() and dotfiles.