Front-end assets, downloads and SPAs are served this way in many deployments.
express.static(root, options) is built-in middleware that serves files from a directory. A request for /css/site.css is answered with root/css/site.css if it exists; otherwise it calls next() and later routes get a chance.
app.use(express.static(path.join(import.meta.dirname, "public")));Always pass an absolute path built from import.meta.dirname; a relative path depends on where the process was started.
Useful patterns:
- Mount path:
app.use("/static", express.static(dir))servesdir/logo.pngat/static/logo.png. - Several folders: call
express.staticmore than once; they are tried in order. - Caching:
maxAge: "1d"setsCache-Control. For fingerprinted build output (app.3f9a1c.js) usemaxAge: "1y", immutable: true. ETag and Last-Modified are on by default, so browsers can revalidate cheaply. - index files: a request for a directory serves its
index.html; disable withindex: false. - Single-page apps: after the static middleware, add a catch-all that returns
index.htmlfor client-side routes:app.get("/{*splat}", (req, res) => res.sendFile(indexPath)). Put API routes before it.
Security defaults: path traversal (/../secret) is blocked, and in Express 5 dotfiles are ignored (404), including whole dot-directories. If you need /.well-known for app links or certificates, serve that folder explicitly with { dotfiles: "allow" }.
In production, a CDN or reverse proxy (nginx) usually serves static assets faster and keeps the load off Node; express.static remains perfect for development and smaller deployments.
Example
import express from "express";
import path from "node:path";
const app = express();
const publicDir = path.join(import.meta.dirname, "public");
const distDir = path.join(import.meta.dirname, "dist");
// /favicon.ico, /robots.txt ... cached for a day
app.use(express.static(publicDir, { maxAge: "1d" }));
// Fingerprinted bundles: cache forever
app.use("/assets", express.static(path.join(distDir, "assets"), { maxAge: "1y", immutable: true }));
// Dot-directories are ignored by default in Express 5 — opt in explicitly
app.use("/.well-known", express.static(path.join(publicDir, ".well-known"), { dotfiles: "allow" }));
app.get("/api/time", (req, res) => res.json({ now: new Date().toISOString() }));
// SPA fallback: any other GET returns the app shell
app.get("/{*splat}", (req, res) => res.sendFile(path.join(distDir, "index.html")));
app.listen(3000);Remember
express.static(absDir)serves files and callsnext()when none matches- Use mount paths and long
maxAge+immutablefor fingerprinted assets - Express 5 ignores dotfiles by default; allow
.well-knownexplicitly
Watch out for
- Passing a relative directory, which breaks when the app is started from another folder.