Skip to content

nginx: only serve the SPA shell to browser navigation - #19

Open
albertotb wants to merge 1 commit into
mainfrom
claude/nginx-spa-fallback
Open

nginx: only serve the SPA shell to browser navigation#19
albertotb wants to merge 1 commit into
mainfrom
claude/nginx-spa-fallback

Conversation

@albertotb

Copy link
Copy Markdown
Member

Tightens the SPA fallback, taking the semantics from FastAPI's new app.frontend().

The bug

try_files $uri $uri/ /index.html answers every unmatched path with the HTML shell and a 200. So a mistyped bundle path or a fetch('/typo') gets HTML where JS or JSON was expected, and the real problem ("that path doesn't exist") only shows up later as a parse error. /assets/* was already safe — that location block has no try_files — but nothing else was.

The rule

Only GET/HEAD requests that accept HTML get the shell; everything else keeps its 404. That is what browsers send when navigating, and it is the same rule app.frontend() uses, so a project that later serves the build from FastAPI instead of nginx behaves identically.

location / {
    try_files $uri $uri/ @spa;
}

location @spa {
    if ($request_method !~ ^(GET|HEAD)$) { return 404; }
    if ($http_accept !~* "text/html|application/xhtml\+xml") { return 404; }
    add_header Cache-Control "no-cache";
    try_files /index.html =404;
}

The add_header is repeated because the fallback is served from @spa directly and so does not pass through location = /index.html. Verified: /about still comes back no-cache, and hashed assets keep immutable.

Verified against real nginx

Both configs, in the nginxinc/nginx-unprivileged:1.29-alpine image, with a dist-shaped tree — and nginx.compose.conf against a stand-in backend so the /api proxy was live:

request before after
GET / 200 shell 200 shell
GET /about (Accept: text/html) 200 shell 200 shell
GET /nested/deep/link (browser Accept) 200 shell 200 shell
GET /assets/missing.js 404 404
GET /typo.json (Accept: application/json) 200 HTML 404
GET /missing.png (Accept: image/*) 200 HTML 404
POST /about 200 HTML 404
GET /api/health (proxy) 200 200
GET /api/missing (proxied 404) 404 404

One deliberate behaviour change to know about: curl http://host/about with no Accept header now returns 404, because */* is not a request for HTML — that is exactly the case (XHR/fetch defaults) the fallback should not swallow. Add -H 'Accept: text/html' when testing deep links by hand. curl http://host/ is unaffected: / is served by the index directive, not the fallback, so the Docker job's existing check still passes.

CI

The Docker job now asserts both halves of the rule — a client route returns the shell, a missing asset returns 404 — so the fallback cannot silently loosen again.

Not included

This does not adopt app.frontend() itself. That would need the API moved off / (it collides with the SPA entry point, since path operations win) and the frontend build copied into the backend image — both in backend/, which is a rendered mirror of python-copier-template. Borrowing the fallback rule is the part that stands alone.

🤖 Generated with Claude Code

https://claude.ai/code/session_011texLkDBELWbXsBf6San3M


Generated by Claude Code

The SPA fallback answered every unmatched path with index.html and a 200,
including missing assets and mistyped fetch() paths — a "not found" then
surfaces as a parse error somewhere downstream instead of a 404.

Restrict the fallback to GET/HEAD requests that accept HTML, which is what
browsers send when navigating; everything else keeps its 404. This is the
rule FastAPI's app.frontend() applies, so a stack that later serves the
build from FastAPI behaves the same way.

The Docker job now asserts both halves of the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants