Serving static files

Express · Essentials · 9 min

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)) serves dir/logo.png at /static/logo.png.
  • Several folders: call express.static more than once; they are tried in order.
  • Caching: maxAge: "1d" sets Cache-Control. For fingerprinted build output (app.3f9a1c.js) use maxAge: "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 with index: false.
  • Single-page apps: after the static middleware, add a catch-all that returns index.html for 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 calls next() when none matches
  • Use mount paths and long maxAge + immutable for fingerprinted assets
  • Express 5 ignores dotfiles by default; allow .well-known explicitly

Watch out for

  • Passing a relative directory, which breaks when the app is started from another folder.

Check yourself

  1. app.use('/static', express.static('/srv/app/public')). Which URL serves /srv/app/public/img/a.png?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/starter/static-files