django-spaserve
[!IMPORTANT] 🤖 AI coding-agent project — this package was developed with the assistance of an AI coding agent. Please review the code and tests before relying on it in production.
Serve a built single-page-application (React / Vue / Svelte dist/ output) from
Django with correct client-side-routing fallback — no nginx/Apache/CDN rewrite
rules required.
django-spaserve ports the navigation heuristic from FastAPI's app.frontend() feature
to Django, solving the three-way decision every SPA server must get right:
- The path is a real built file (
/assets/app.abc123.js) → serve it with the right content-type and caching. - The path is a client-side route (
/dashboard/settings) → serveindex.htmlwith200so the JS router can render it. - The path is a genuinely missing asset / API 404 (
/assets/typo.js) → return a real404, not the SPA shell.
The trick for distinguishing (2) from (3) is a navigation-request heuristic: the
shell is served only when the path has no file extension and the Accept header
looks like a browser HTML navigation. This means curl (*/*) and browsers get the
shell, a fetch() asking for JSON does not, and a missing .js always 404s.
Inspiration & prior art
django-spaserve is a port of FastAPI's app.frontend() / router.frontend() feature
(shipped June 2026), which serves a SPA build in one line and replaced the manual
StaticFiles(html=True) + catch-all dance. We wanted the same ergonomics for Django.
The whole FastAPI feature lives in one file, and the navigation heuristic — the single most valuable thing to port faithfully — is copied near-verbatim:
- Feature PR (motivation + design discussion):
fastapi/fastapi#15800 - Reference implementation:
fastapi/routing.py@a497a02
How FastAPI's pieces map onto this package:
| FastAPI | Purpose | django-spaserve |
|---|---|---|
_low_priority_routes (matched after all real routes) |
the SPA never shadows the API | a catch-all re_path placed last (Strategy A) or handler404 (Strategy B) — Django has no built-in "match last" bucket |
_FrontendStaticFiles.get_response |
the three-way decision | decide_response() |
_is_frontend_navigation_request / _iter_accept_media_types |
nav-vs-asset disambiguation | navigation.is_navigation_request() (ported near-verbatim) |
_FrontendRoute(Group), _frontend_path_specificity |
multiple SPAs, longest-prefix wins | spa_urls_multi() + SpaConfig.specificity |
_normalize_frontend_path / _join_frontend_paths |
prefix normalization | config.normalize_frontend_path() / join_frontend_paths() |
fallback="auto"|"index.html"|"404.html" |
SPA vs SSG-export behavior | the same fallback param |
Starlette StaticFiles(follow_symlink=False) |
traversal/symlink-safe file lookup | files.lookup_path() (os.path.realpath + containment check) |
check_dir RuntimeError (resolved abs path) |
startup validation | Django system checks (django_spaserve.checks) |
The same frontend motivation appears in other frameworks; this is the Django take on
the idea. Credit for the design and the heuristic goes to the FastAPI authors.
Install
pip install django-spaserve
Requires Django ≥ 4.2 and Python ≥ 3.10. No dependencies beyond Django.
Quickstart
Strategy A — catch-all (great for dev & single-SPA-at-root)
spa_urls() serves both real files and the shell fallback itself. Put it last in
your root urlpatterns — anything include()d after it is shadowed.
# urls.py
from django.contrib import admin
from django.urls import path, include
from django_spaserve import spa_urls
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("myapi.urls")),
*spa_urls("/", directory=BASE_DIR / "frontend/dist"), # MUST be last
]
Strategy B — handler404 (the blessed production setup)
Django invokes handler404 only after every real route has failed to match, so it
cannot shadow your API. Pair it with WhiteNoise
(or a CDN) serving the hashed asset files; django-spaserve then only decides shell-vs-404.
# settings.py
DJANGO_SPASERVE = [
{"prefix": "/", "directory": BASE_DIR / "frontend/dist", "fallback": "index.html"},
]
INSTALLED_APPS = [..., "django_spaserve"] # optional: enables `manage.py check` validation
# urls.py
handler404 = "django_spaserve.handler404"
Strategy A (spa_urls) |
Strategy B (handler404) |
|
|---|---|---|
| Serves real files | ✅ yes | ❌ no (use WhiteNoise/CDN) |
| Can shadow the API | ⚠️ if placed wrong | ✅ never |
| Best for | dev, single SPA at / |
production behind WhiteNoise/CDN |
Fallback modes
fallback controls what happens when no real file matches:
-
"auto"(default) — if404.htmlexists, serve it (with a404) for every miss; otherwise serveindex.htmlfor navigations. Best for SPAs that don't ship a404.html, and for static-site exports that do.Note: in
automode a present404.htmltakes precedence over the shell, so client-side deep links (/dashboard/settings) would render404.htmlrather than the app. If your SPA handles its own routing, either omit404.htmlor setfallback="index.html"explicitly. (This matches FastAPI'sapp.frontend().) -
"index.html"— always serve the shell for navigations (classic SPA). -
"404.html"— always serve404.htmlwith a404status (static-site export). -
None— never fall back; missing paths always404.
Multiple SPAs
Mount several builds at different prefixes; the most specific (longest) prefix wins.
# Strategy A
urlpatterns = [
path("api/", include("myapi.urls")),
*spa_urls_multi([
{"prefix": "/admin-spa", "directory": BASE_DIR / "admin/dist"},
{"prefix": "/", "directory": BASE_DIR / "app/dist"},
]),
]
# Strategy B
DJANGO_SPASERVE = [
{"prefix": "/admin-spa", "directory": BASE_DIR / "admin/dist"},
{"prefix": "/", "directory": BASE_DIR / "app/dist"},
]
/admin-spa/users hits the admin build; /dashboard hits the root build. Ordering is
handled for you (longest-prefix-first), regardless of how you list the mounts.
Production notes
-
Preferred setup: WhiteNoise serves the hashed assets (compression + far-future caching);
django-spaservehandles only the shell fallback viahandler404. This splits "serve real files fast" from "decide shell vs 404" cleanly. -
Never cache
index.html. The shell is served withCache-Control: no-cacheby default (index_cache_control) so users never get a stale app after a deploy. Cache hashed assets aggressively (asset_cache_control, or let WhiteNoise do it). -
If a CDN/edge already does SPA rewrites, this app is a harmless no-op fallback for origin requests and still gives you a correct local-dev experience.
-
Security headers (CSP, etc.) are out of scope — see
django-csp. For CSP-nonce or runtime-env injection into the shell, use the optionalhtml_transformhook (off by default):def inject_nonce(html: bytes, request) -> bytes: return html.replace(b"__CSP_NONCE__", request.csp_nonce.encode()) spa_urls("/", directory=DIST, html_transform=inject_nonce)
Configuration reference (SpaConfig)
| field | default | meaning |
|---|---|---|
directory |
— | the SPA build output directory (required) |
prefix |
"/" |
URL prefix to mount at |
fallback |
"auto" |
"auto" / "index.html" / "404.html" / None |
check_dir |
True |
validate directory/fallback existence at startup |
index_cache_control |
"no-cache" |
Cache-Control for the shell |
asset_cache_control |
None |
Cache-Control for real files |
html_transform |
None |
(html_bytes, request) -> bytes shell transform |
How it works
The navigation heuristic and three-way decision are faithful ports of FastAPI's
_is_frontend_navigation_request
and
_FrontendStaticFiles.get_response
(see Inspiration & prior art). The Accept-header q-value
parsing matches FastAPI's use of email.message.Message. File lookup refuses path
traversal and symlink escapes (os.path.realpath + containment check, mirroring
Starlette's follow_symlink=False). Only GET/HEAD are allowed; other methods
get 405. A complete, runnable Django + Vite/React example lives in example/.
Development
pip install -e ".[dev]"
pytest # run the test suite
ruff check . && ruff format --check .
CI (.github/workflows/ci.yml) runs the suite across
Python 3.10–3.13 × Django 4.2/5.0/5.1/5.2, lints with ruff, and builds the example SPA.
Releasing
Publishing is automated via .github/workflows/release.yml
using PyPI Trusted Publishing (OIDC — no tokens). To cut a release:
- Bump
versioninpyproject.toml. - Tag and push:
git tag vX.Y.Z && git push --tags(the build job verifies the tag matches the package version). - Publish a GitHub Release for that tag — the
publishjob uploads to PyPI and attaches the sdist/wheel to the release.
One-time setup: add a trusted publisher on PyPI (repo bas-h/django-spaserve, workflow
release.yml, environment pypi) and create a pypi environment in the repo settings.
Credits & license
Ported from FastAPI's app.frontend() feature
(PR #15800) — design and the navigation
heuristic are theirs. FastAPI and Starlette are MIT-licensed.
This package is released under the MIT License (see LICENSE).
Developed with the assistance of an AI coding agent.
Release files for django-spaserve 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_spaserve-0.1.0.tar.gz | 91.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_spaserve-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 109.1 kB
Release files / django_spaserve-0.1.0.tar.gz
| Download URL | django_spaserve-0.1.0.tar.gz |
|---|---|
| Size | 91.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1039ceed2b615d91e4d45dc134373660a3e725616eca5cff3b335f685b90c463
|
|
BLAKE2b-256 checksum How to use checksums |
929a2a51f92dd3bd8c2edcaf95ce403fd4249e2afc128d90f7d342ff10397806
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 22, 2026.
Transparency logRelease files / django_spaserve-0.1.0-py3-none-any.whl
| Download URL | django_spaserve-0.1.0-py3-none-any.whl |
|---|---|
| Size | 18.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
116a2fcb8db553806873285e8a0afaed3c6abbbc5cfb329ec2964056914e9107
|
|
BLAKE2b-256 checksum How to use checksums |
091c97c904ebd5949943103d8469ec6923d731df9660b518e5a7fab25cd2003f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.13
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jun 22, 2026.
Transparency log