Serve a built single-page-application (SPA) from Django with correct client-side-routing fallback — no nginx/Apache/CDN rewrite rules required.
Project description
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_spaserve-0.1.0.tar.gz.
File metadata
- Download URL: django_spaserve-0.1.0.tar.gz
- Upload date:
- Size: 91.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1039ceed2b615d91e4d45dc134373660a3e725616eca5cff3b335f685b90c463
|
|
| MD5 |
d0f82fe39a5b3da5fba390e605c6da63
|
|
| BLAKE2b-256 |
929a2a51f92dd3bd8c2edcaf95ce403fd4249e2afc128d90f7d342ff10397806
|
Provenance
The following attestation bundles were made for django_spaserve-0.1.0.tar.gz:
Publisher:
release.yml on bas-h/django-spaserve
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_spaserve-0.1.0.tar.gz -
Subject digest:
1039ceed2b615d91e4d45dc134373660a3e725616eca5cff3b335f685b90c463 - Sigstore transparency entry: 1911928032
- Sigstore integration time:
-
Permalink:
bas-h/django-spaserve@46e542170d005ed21da025d078c65b1f158d3cb1 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/bas-h
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@46e542170d005ed21da025d078c65b1f158d3cb1 -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_spaserve-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_spaserve-0.1.0-py3-none-any.whl
- Upload date:
- Size: 18.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
116a2fcb8db553806873285e8a0afaed3c6abbbc5cfb329ec2964056914e9107
|
|
| MD5 |
072a1bf87bfea5e157daa4750e5db5e2
|
|
| BLAKE2b-256 |
091c97c904ebd5949943103d8469ec6923d731df9660b518e5a7fab25cd2003f
|
Provenance
The following attestation bundles were made for django_spaserve-0.1.0-py3-none-any.whl:
Publisher:
release.yml on bas-h/django-spaserve
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_spaserve-0.1.0-py3-none-any.whl -
Subject digest:
116a2fcb8db553806873285e8a0afaed3c6abbbc5cfb329ec2964056914e9107 - Sigstore transparency entry: 1911928111
- Sigstore integration time:
-
Permalink:
bas-h/django-spaserve@46e542170d005ed21da025d078c65b1f158d3cb1 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/bas-h
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@46e542170d005ed21da025d078c65b1f158d3cb1 -
Trigger Event:
release
-
Statement type: