NodeJSServing Static Files

Serving Static Files

Web apps need to serve static assets — CSS, client-side JavaScript, images, fonts, downloadable files. In raw Node this meant building a safe file server by hand (MIME types, path-traversal checks, caching). Express collapses all of that into one middleware: express.static(). It handles the security and headers correctly so you don't reimplement them.

The basics

JS
import express from 'express'
import { join } from 'node:path'

const app = express()

// Serve everything in ./public at the URL root:
app.use(express.static(join(import.meta.dirname, 'public')))

app.listen(3000)
// public/style.css      → GET /style.css
// public/img/logo.png   → GET /img/logo.png
Use an absolute path — anchor to the module
Pass `express.static` an absolute path built from `import.meta.dirname` (or `__dirname` in CommonJS). A relative path resolves against `process.cwd()` — the [cwd trap](/nodejs/file-paths) — so the same app breaks when launched from a different directory. Anchoring to the module makes it location-independent.
Mounting under a path prefix

JS
// Serve files under a /static URL prefix instead of the root:
app.use('/static', express.static('public'))
// public/style.css  →  GET /static/style.css

// Multiple directories — checked in order, first match wins:
app.use(express.static('public'))
app.use(express.static('uploads'))
Multiple static dirs are searched in registration order
Register `express.static` more than once and Express tries each directory in turn, serving the first file it finds. Order them by priority (e.g. a build output dir before a fallback). A request that matches no static file falls through to your subsequent routes.
Security and caching are handled for you
`express.static` already prevents path traversal — don't roll your own
The middleware (built on the `serve-static` package) safely confines requests to the served directory, blocking `../` traversal attacks, sets correct `Content-Type` from file extensions, supports conditional requests (`ETag`/`Last-Modified` → `304`), and handles byte-range requests for media. Reproducing all this by hand — as the [raw static server](/nodejs/serving-html) showed — is error-prone. Use the middleware; it gets the security right.
Useful options

JS
app.use(express.static('public', {
  maxAge: '1d',                 // Cache-Control max-age (or ms)
  etag: true,                   // conditional requests (default on)
  index: 'index.html',          // file served for a directory (default)
  dotfiles: 'ignore',           // don't serve .env, .git, etc.
  extensions: ['html'],         // /about → about.html fallback
  immutable: true,              // pair with maxAge for fingerprinted assets
}))

Option

Purpose

maxAge

Browser cache duration (Cache-Control)

immutable

Tell browsers the file never changes (hashed assets)

index

Default file for a directory request

dotfiles

'ignore' / 'deny' / 'allow' dotfiles

extensions

Try these extensions for extensionless URLs

fallthrough

Pass to next middleware on miss (default true)

Set `dotfiles: 'ignore'` (or 'deny') to avoid leaking config files
By default `express.static` does not serve dotfiles, but if your static directory could contain `.env`, `.git`, or other hidden files, be explicit with `dotfiles: 'ignore'` or `'deny'`. Never point static serving at a directory that holds secrets or source — serve a dedicated `public`/build folder only.
Caching strategy for built assets

Modern build tools fingerprint filenames (app.a1b2c3.js). Because the name changes when content changes, you can cache such files forever:

JS
// Hashed, immutable assets — cache aggressively:
app.use('/assets', express.static('dist/assets', {
  maxAge: '1y',
  immutable: true,        // browser won't even revalidate
}))

// HTML entry point — never cache (so new asset URLs are picked up):
app.use(express.static('dist', {
  setHeaders: (res, path) => {
    if (path.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache')
  },
}))
Cache fingerprinted assets long, HTML short
The pattern: cache content-hashed assets for a year with `immutable` (the URL changes when content does, so stale cache is impossible), but keep the HTML uncached so browsers always fetch the latest page — which references the new asset URLs. This gives maximum caching with instant deploys.
Production: let a CDN/proxy serve static
In production, prefer a CDN or reverse proxy for static assets
`express.static` is convenient and correct, but every static request still ties up a Node event-loop tick. At scale, put static files behind a CDN (CloudFront, Cloudflare) or let a reverse proxy (nginx) serve them directly — both are far faster and free Node to handle dynamic requests. Use `express.static` for development, small apps, or assets that must be served by the app itself.
Static + SPA fallback

A single-page app needs all unknown routes to return index.html (the client router takes over). Combine static serving with a catch-all:

JS
app.use(express.static('dist'))

// API routes first...
app.use('/api', apiRouter)

// ...then send index.html for everything else (client-side routing):
app.get('/*splat', (req, res) => {
  res.sendFile(join(import.meta.dirname, 'dist', 'index.html'))
})
Put the SPA fallback AFTER API routes and static files
The catch-all must come last so it doesn't swallow `/api/*` or real asset requests — [order matters](/nodejs/express-routing). Note the Express 5 named-wildcard syntax `/*splat` (in v4 it was `*`). Static files are tried first, then API routers, and only unmatched paths fall through to `index.html`.
Next
The concept that powers all of Express — composable request handlers: [What is Middleware?](/nodejs/middleware-intro).