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
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.pngMounting under a path prefix
// 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'))Security and caching are handled for you
Useful options
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 |
|---|---|
| Browser cache duration ( |
| Tell browsers the file never changes (hashed assets) |
| Default file for a directory request |
| 'ignore' / 'deny' / 'allow' dotfiles |
| Try these extensions for extensionless URLs |
| Pass to next middleware on miss (default true) |
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:
// 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')
},
}))Production: let a CDN/proxy serve static
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:
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'))
})