Serve SPAs (Vue, React, Angular, vanilla HTML) directly from Django
Project description
dj-serve
Serve SPAs (Vue, React, Angular, Svelte, or vanilla HTML) directly from Django — no separate static server needed.
from dj_serve import dj_serve
urlpatterns = [
dj_serve("/", "dist/", "index.html", "dist/400.html", "dist/500.html"),
]
Why?
Traditional Django + SPA setups require either:
- A separate static file server (nginx, Apache, CDN) — adds deployment complexity.
django.contrib.staticfiles— not designed for SPAs; no client-side routing fallback, no custom error pages per frontend.
dj-serve solves both:
| Feature | staticfiles |
dj-serve |
|---|---|---|
| SPA fallback (client-side routing) | ❌ | ✅ |
| Custom 400/500 pages per SPA | ❌ | ✅ |
| Isolated error handling (no global handlers) | ❌ | ✅ |
| Path traversal protection | ❌ | ✅ |
| Correct MIME types | ✅ | ✅ |
| Works with any prefix | ✅ | ✅ |
Installation
pip install dj-serve
Requires Django ≥ 4.0 and Python ≥ 3.10.
Usage
Basic SPA
from django.urls import include, path
from dj_serve import dj_serve
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("myapi.urls")),
dj_serve("/", "dist/"),
]
dj_serve("/", "dist/") will:
- Serve files from
dist/(e.g.,dist/style.cssat/style.css). - For any other route under
/, servedist/index.html— this enables client-side routing (Vue Router, React Router, etc.). - Return 404 if neither the file nor
index.htmlexists.
With custom error pages
dj_serve("/", "dist/", error_400="dist/400.html", error_500="dist/500.html")
Error pages are served only for routes under this prefix — your API and admin endpoints keep their default error handling.
With a prefix
dj_serve("/app", "dist/", "index.html")
Serves the SPA at /app/, /app/about, /app/dashboard, etc.
Vanilla HTML site
dj_serve("/", "site/", entry_point="index.html")
Works the same way — SPA fallback just means unknown routes serve index.html.
Cache-Control headers
dj_serve("/", "dist/", cache_control="public, max-age=3600")
Apply the same value to all responses, or use a dict with glob patterns:
dj_serve("/", "dist/", cache_control={
"*.html": "no-cache",
"*.css": "public, max-age=31536000, immutable",
"*.js": "public, max-age=31536000, immutable",
"*": "public, max-age=3600",
})
cache_control |
Behaviour |
|---|---|
None (default) |
No Cache-Control header |
str |
Same value for every response |
dict[str, str] |
Glob patterns matched against the filename; first match wins |
API
def dj_serve(
prefix: str,
dist_dir: str,
entry_point: str = "index.html",
error_400: str | None = None,
error_500: str | None = None,
cache_control: str | dict[str, str] | None = None,
async_mode: bool = False,
) -> URLPattern:
| Argument | Default | Description |
|---|---|---|
prefix |
— | URL prefix (e.g., /, /app) |
dist_dir |
— | Path to the directory with static files |
entry_point |
"index.html" |
HTML file to serve for SPA fallback (client-side routing) |
error_400 |
None |
Path to a custom 400 error page |
error_500 |
None |
Path to a custom 500 error page |
cache_control |
None |
Cache-Control header value. str for all files, dict for per-pattern (glob) |
async_mode |
False |
Use async I/O with aiofiles. Requires pip install dj-serve[async] |
How it works
- Django resolves the URL through the
re_pathpattern. - The view looks for the requested file in
dist_dir. - If found → served with the correct MIME type.
- If not found → serves
entry_point(SPA fallback). - If the entry point is missing → serves the custom
error_400page, or 404. - If an exception occurs → serves the custom
error_500page, or 500.
All error handling is contained within the view — no global handler400/handler500 configuration needed.
ASGI Support
dj-serve works with both WSGI and ASGI servers (uvicorn, daphne, hypercorn).
Synchronous mode (default)
dj_serve("/", "dist/")
Uses synchronous I/O. Django runs the view in a threadpool under ASGI.
Async mode
dj_serve("/", "dist/", async_mode=True)
Uses non-blocking I/O with aiofiles. Best for ASGI servers under high concurrency.
Install the async extra:
pip install dj-serve[async]
Production Deployment
For production, dj-serve provides dj_serve_middleware() which wraps your Django application with production-grade static file serving:
- WSGI (gunicorn, waitress, uwsgi, etc.) → uses WhiteNoise
- ASGI (uvicorn, daphne, hypercorn, etc.) → uses WhiteSnout
Installation
# For WSGI deployments
pip install dj-serve[wsgi]
# For ASGI deployments
pip install dj-serve[asgi]
# For both
pip install dj-serve[wsgi,asgi]
WSGI Deployment
In your wsgi.py:
from django.core.wsgi import get_wsgi_application
from dj_serve import dj_serve_middleware
application = get_wsgi_application()
application = dj_serve_middleware(application, "dist/")
ASGI Deployment
In your asgi.py:
from django.core.asgi import get_asgi_application
from dj_serve import dj_serve_middleware
application = get_asgi_application()
application = dj_serve_middleware(application, "dist/", async_mode=True)
Advanced Configuration
Pass additional options to the underlying middleware:
# WSGI with WhiteNoise options
application = dj_serve_middleware(
application,
"dist/",
max_age=86400, # 1 day cache
allow_all_origins=True,
)
# ASGI with WhiteSnout options
application = dj_serve_middleware(
application,
"dist/",
async_mode=True,
cache_max_age=86400,
security_headers=True,
)
How It Works
The middleware and URL pattern work together:
- Middleware intercepts requests for static files (CSS, JS, images) and serves them with compression, caching, and security headers
dj_serve()URL pattern handles SPA routing — when a file isn't found, it servesindex.htmlfor client-side routing
# urls.py — SPA fallback (always needed)
from dj_serve import dj_serve
urlpatterns = [
dj_serve("/", "dist/"),
]
# wsgi.py or asgi.py — production static file serving
from dj_serve import dj_serve_middleware
application = dj_serve_middleware(application, "dist/", async_mode=True)
Backend Comparison
| Feature | Builtin | WhiteNoise (WSGI) | WhiteSnout (ASGI) |
|---|---|---|---|
| SPA fallback | ✅ | ❌ (needs dj-serve) | ❌ (needs dj-serve) |
| Compression | ❌ | gzip + brotli | gzip + brotli |
| ETag / 304 | ❌ | ✅ | ✅ |
| Async native | ❌ | ❌ (WSGI) | ✅ |
| Rust acceleration | ❌ | ❌ | ✅ |
| Best for | Development | WSGI production | ASGI production |
Development vs Production
- Development (
DEBUG=True): Usedj_serve()URL pattern only — no warning - Production (
DEBUG=False): Configuredj_serve_middleware()inwsgi.pyorasgi.py— otherwise a warning is logged
Testing locally
pip install -e ".[dev]"
pytest
License
MIT
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 dj_serve-1.1.0.tar.gz.
File metadata
- Download URL: dj_serve-1.1.0.tar.gz
- Upload date:
- Size: 8.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0db06dcc7ba24b8659c551275826aec2df2c27c0013926a7ba2d4dc727260f34
|
|
| MD5 |
a4a139a4790a9f4e8df113d0f026749e
|
|
| BLAKE2b-256 |
343eee91b37cefc013d4c28046486cfc7143d4e9e90b0d2ef1176e6c61de2b35
|
Provenance
The following attestation bundles were made for dj_serve-1.1.0.tar.gz:
Publisher:
publish.yml on rroblf01/dj-serve
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dj_serve-1.1.0.tar.gz -
Subject digest:
0db06dcc7ba24b8659c551275826aec2df2c27c0013926a7ba2d4dc727260f34 - Sigstore transparency entry: 2115242987
- Sigstore integration time:
-
Permalink:
rroblf01/dj-serve@bb6effd8af74d3654ca32eb591bcfeee7c59879e -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bb6effd8af74d3654ca32eb591bcfeee7c59879e -
Trigger Event:
release
-
Statement type:
File details
Details for the file dj_serve-1.1.0-py3-none-any.whl.
File metadata
- Download URL: dj_serve-1.1.0-py3-none-any.whl
- Upload date:
- Size: 10.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
353635cfd99c9aafeae42c73b358a56ce9e2ec7f2b1d87d7ff1bb732216f5276
|
|
| MD5 |
022161623b8cb859802c4b0642a08094
|
|
| BLAKE2b-256 |
885dc62875a152593ff64d6576b43c27350d902b7ecac683ea991ddc57f5170c
|
Provenance
The following attestation bundles were made for dj_serve-1.1.0-py3-none-any.whl:
Publisher:
publish.yml on rroblf01/dj-serve
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dj_serve-1.1.0-py3-none-any.whl -
Subject digest:
353635cfd99c9aafeae42c73b358a56ce9e2ec7f2b1d87d7ff1bb732216f5276 - Sigstore transparency entry: 2115243133
- Sigstore integration time:
-
Permalink:
rroblf01/dj-serve@bb6effd8af74d3654ca32eb591bcfeee7c59879e -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/rroblf01
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@bb6effd8af74d3654ca32eb591bcfeee7c59879e -
Trigger Event:
release
-
Statement type: