2026-01-28 · 3 min read
Express 5 Removed My Home Page
Every route worked except the one people actually visit, and the fix was two braces in a wildcard.
I deployed, clicked through the nav, and everything worked. /lab rendered. /stack rendered. /field-notes and every post under it rendered. The API answered on every path I threw at it. Then I opened a clean tab, typed the bare domain, and got a 404.
The home page. The one URL that is not optional.
The line that looked right
This site serves the API and the built SPA from the same Express process, so there is exactly one line responsible for handing the React shell to anything that is not /api. It is a wildcard route at the bottom of the stack, and when I read it, it read as correct. Wildcard, send index.html, done. I read it three or four times before I accepted that reading it again was not going to help.
What I had was this:
app.get('/*splat', (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
Which is already the Express 5 form. Bare '*' is gone in 5 — wildcards have to be named now, and '/*splat' is the mechanical translation of the '*' that every SPA tutorial written before the release still shows you. That part I had got right, which is exactly why the bug was hard to see. It was not old code that had failed to keep up. It was new code with one thing wrong in it.
What the braces do
'/*splat' requires the wildcard to match at least one path segment. /lab has one. /field-notes/one-port-is-enough has two. / has none, so the route does not match, and the request falls off the end of the stack into Express's default 404 handler.
The fix is two characters:
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
The braces make the segment optional, so the pattern matches zero or more segments and / is included. That is the entire bug. A route that matched everything except the root, in a pattern language where the difference between those two behaviours is punctuation.
The half of it that was mine
There was a second thing underneath, and it was not Express's doing.
express.static serves index.html for a directory request by default, which means for most of this project's life the static middleware had been quietly answering / before the fallback ever saw it. The wildcard was never handling the home page. It only looked like it was, and the moment the static mount changed shape the bug that had always been there became visible.
So the static mount now says what it means:
app.use(express.static(PUBLIC_DIR, { index: false, maxAge: '1h' }));
With index: false, / is not special. It goes to the fallback like every other client route, which is where the SPA shell was supposed to come from all along. One thing owns the shell instead of two things overlapping and the overlap doing the work.
The cost of a catch-all
A catch-all fallback catches everything, including URLs that are genuinely wrong. /lba does not 404. It returns the shell with a 200, the router boots, finds no match, and renders the client-side not-found page. Search engines and uptime checks see a successful response for a page that does not exist, and that is a real cost of client-side routing, not something I have engineered around.
The one place I refuse to extend it is the API. Unmatched /api/* gets an explicit JSON 404 mounted above the fallback, because an API client that asks for a mistyped endpoint and receives an HTML document with a 200 has been handed the worst possible answer: not an error, just something that will fail later somewhere less obvious.
app.use('/api', apiNotFound);
The order matters. That line has to sit above the wildcard, or the wildcard eats it too.
The whole incident produced a two-character diff and one changed option. I read wildcard patterns more slowly now, because this is a class of syntax whose absence does not announce itself.