Express foundations

Serve static assets

Mount static files at a clear URL boundary and understand cache and path behavior.

The notes app has one stylesheet and one small script. Express serves them with express.static(), a middleware that maps a URL prefix to a folder on disk. Everything inside that folder becomes public. Nothing outside it does.

That last sentence is the whole security story of static files. Pick the folder carefully.

Mount a small public folder

Create public/notes.css in the project. Then mount the folder under /assets in createApp():

import path from 'node:path'
import express from 'express'

const publicDir = path.join(import.meta.dirname, '../public')

app.use('/assets', express.static(publicDir, { maxAge: '1h' }))

Two details matter here.

The path is resolved from the file’s own location with import.meta.dirname. If you write express.static('public'), Express resolves it from the current working directory, the folder you ran node from. Start the app from a systemd unit or a different shell and the stylesheet is suddenly gone.

The prefix /assets is a boundary. A request for /assets/notes.css reads public/notes.css. A request for /notes.css does not touch the disk at all and moves on to the routes.

Check the headers

Start the server and ask for the file:

curl -I http://localhost:3000/assets/notes.css
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Content-Type: text/css; charset=utf-8
ETag: W/"1c2-19a0b3f5c40"

maxAge: '1h' became max-age=3600. Browsers keep the file for an hour without asking. Send the ETag back and you get a 304 Not Modified with no body, which is how repeat visits stay cheap.

If you fingerprint filenames at build time, like notes.3f9a1c.css, you can raise maxAge to a year. If you don’t, keep it short or users will see stale CSS after a deploy.

Watch what does not get served

Three requests tell you the boundary holds:

curl -i http://localhost:3000/assets/notes.css      # 200
curl -i http://localhost:3000/assets/missing.css    # 404
curl -i --path-as-is http://localhost:3000/assets/../src/app.js

The missing file gets a 404, but not from express.static(). The middleware calls next() when it finds nothing, and the request continues to the rest of the app, where the final not-found handler answers. That’s by design: a missing asset should not stop /assets/whatever from being a real route if you want one.

The third request tries to climb out of the folder. --path-as-is stops curl from cleaning the .. itself. Express answers 403 Forbidden, because the underlying send library refuses any path that escapes the root. Dotfiles like .env are ignored by default too.

One mistake I see often: app.use(express.static(import.meta.dirname)), serving the project root. It works in the demo and it publishes package.json, your source, and anything else in the folder. Serve the smallest folder that holds only public files, and keep uploads and source elsewhere.

Lesson completed