Serve Django and a React SPA on the Same Domain
Serve a React SPA and a Django API from one domain with Nginx: routing fallback, API proxying, cache headers, and CSRF-safe session auth.
Problem statement
Many Django projects pair a Django API with a React (or Vue, Svelte) single-page application built by Vite. Hosting them on separate domains means CORS configuration, cross-site cookies, and third-party cookie restrictions in browsers. Hosting both on one domain avoids all of that, but it introduces its own production problems:
- refreshing a client-side route such as
/orders/42returns a 404 from Nginx or Django - the SPA's
index.htmlgets cached, so users keep loading an old bundle after a deploy /static/is claimed by both Django'scollectstaticoutput and the frontend build- requests to
/api/or/admin/accidentally fall through to the SPA and return HTML with status 200 - session authentication fails with CSRF errors on
POSTrequests from the SPA
This guide serves the built SPA directly from Nginx and proxies API, admin, and Django static paths to Django, all on https://www.example.com.
Quick answer
- Build the SPA to a directory such as
/srv/myapp/frontend/dist, with hashed asset filenames under/assets/. - In Nginx, proxy
/api/and/admin/to Gunicorn, and serve Django's collected files from/static/. - Serve the SPA from
location /withtry_files $uri /index.html;so client-side routes load the app. - Cache
/assets/for a year as immutable, and sendCache-Control: no-cacheforindex.html. - Keep API 404s as JSON by never letting
/api/fall back toindex.html. - Use Django session auth: fetch a CSRF cookie, then send
X-CSRFTokenon unsafe requests. - Verify deep links, cache headers, and a
POSTwith CSRF.
Step-by-step solution
1. Decide the URL layout
| Path | Served by |
|---|---|
/api/ | Django (Gunicorn) |
/admin/ | Django (Gunicorn) |
/static/ | Nginx, from Django's STATIC_ROOT |
/assets/ | Nginx, from the SPA build |
| everything else | Nginx, SPA index.html fallback |
Keeping the SPA's assets under /assets/ (Vite's default) and Django's under /static/ means the two build outputs never overwrite each other.
2. Build the frontend
With Vite, the defaults already fit:
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/",
build: {
outDir: "dist",
assetsDir: "assets",
},
server: {
proxy: {
"/api": "http://127.0.0.1:8000",
},
},
});
The dev server proxy mirrors the production layout, so frontend code can call relative URLs like /api/orders/ in both environments.
Build in CI and copy the result to the server:
npm ci
npm run build
rsync -a --delete dist/ deploy@server:/srv/myapp/frontend/dist/
Copy into a new release directory and switch a symlink instead if you want atomic frontend deploys; rsync --delete briefly leaves a mix of old and new files.
3. Django URL and settings
# config/urls.py
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("api.urls")),
]
# settings.py
ALLOWED_HOSTS = ["www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
STATIC_URL = "/static/"
STATIC_ROOT = "/srv/myapp/static"
No CORS package is needed because the browser sees one origin. CSRF_COOKIE_HTTPONLY must stay False (the default) so JavaScript can read the CSRF cookie.
4. Nginx configuration
upstream django {
server unix:/run/myapp/gunicorn.sock;
}
server {
listen 443 ssl;
http2 on;
server_name www.example.com;
# ssl_certificate and ssl_certificate_key from Certbot
root /srv/myapp/frontend/dist;
location /api/ {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /admin/ {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /srv/myapp/static/;
expires 30d;
}
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location / {
try_files $uri /index.html;
}
}
How the SPA locations work:
location /serves a real file if one exists (for example/favicon.ico), otherwise internally redirects to/index.html, so/orders/42loads the app and React Router takes over.- The internal redirect lands in
location = /index.html, which addsCache-Control: no-cache. Browsers revalidate the HTML on every load, so a new deploy's asset references are picked up immediately. /assets/returns a real404for missing files instead of falling back to HTML. Otherwise a missing JavaScript chunk would returnindex.htmlwith a200status and a confusing "Unexpected token '<'" error./api/and/admin/are proxied before the catch-all, so API 404s come from Django as JSON rather than as the SPA shell.
If you run the http2 directive on an Nginx version older than 1.25.1, use listen 443 ssl http2; instead.
sudo nginx -t && sudo systemctl reload nginx
5. CSRF with session authentication
Provide an endpoint that sets the CSRF cookie:
# api/views.py
from django.http import JsonResponse
from django.views.decorators.csrf import ensure_csrf_cookie
@ensure_csrf_cookie
def csrf(request):
return JsonResponse({"detail": "CSRF cookie set"})
Call it once when the app loads, then include the token on unsafe requests:
function getCookie(name) {
return document.cookie
.split("; ")
.find((row) => row.startsWith(`${name}=`))
?.split("=")[1];
}
await fetch("/api/csrf/", { credentials: "same-origin" });
await fetch("/api/orders/", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify({ sku: "ABC-1", quantity: 2 }),
});
Because the SPA and API share an origin, the session cookie can use SameSite=Lax, and HTTPS origin checks pass with CSRF_TRUSTED_ORIGINS set to the site origin.
6. Deploy order
For a release that changes both API and frontend:
- Deploy the backend and run migrations; keep the API backward compatible with the old frontend.
- Upload the new frontend assets. Old
index.htmlcopies in open tabs still reference old hashed files, so keep the previous release'sassetsavailable for a while (for example by merging asset directories rather than deleting them immediately). - Switch
index.htmlto the new build.
This ordering prevents users with a tab open from hitting missing chunks.
7. Verify
# Deep link returns the SPA shell
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://www.example.com/orders/42
# index.html is not cached long-term
curl -sI https://www.example.com/orders/42 | grep -i cache-control
# Hashed asset is immutable (use a real filename from dist/assets)
curl -sI https://www.example.com/assets/index-3f2a1b.js | grep -i cache-control
# Missing asset is a real 404
curl -s -o /dev/null -w "%{http_code}\n" https://www.example.com/assets/missing.js
# API 404 is JSON from Django
curl -s https://www.example.com/api/nope/
# Admin CSS from Django static
curl -sI https://www.example.com/static/admin/css/base.css | head -1
Verification check:
- deep links return
200withtext/html index.htmlresponses haveCache-Control: no-cache- assets have a one-year immutable cache header
- missing assets and unknown API routes return
404 - a logged-in
POSTsucceeds with the CSRF header and fails with403without it
Rollback
Keep the previous frontend build directory and switch the root symlink back; reload Nginx. Roll back the backend separately with your normal release process. Because the API stays backward compatible across one release, frontend and backend can be rolled back independently.
Explanation
The single-origin setup moves routing decisions into Nginx: API and admin paths go to Django, build artifacts are served as files, and anything else is treated as a client-side route. Cache behavior follows file naming: content-hashed assets are safe to cache forever, while the HTML entry point must always be revalidated because it is the only file that tells the browser which hashed assets to load.
Edge cases / notes
Django serving the SPA instead of Nginx
On platforms without Nginx, you can serve index.html from a Django catch-all view and the assets with WhiteNoise. Put the catch-all last in urlpatterns and exclude api/ and admin/ prefixes.
Server-side rendering and SEO
A client-rendered SPA returns the same HTML shell for every route. If search visibility of those routes matters, consider prerendering or server-side rendering.
Service workers
A service worker can serve stale bundles even with correct HTTP caching. Version it and ensure it updates on deploy.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Blank page, console shows Unexpected token '<' | a JavaScript file request returned index.html | ensure /assets/ uses try_files $uri =404; check base in Vite config |
/admin/ shows the React app | catch-all location matched before the admin proxy | define /admin/ location; confirm Nginx config reloaded |
API POST returns 403 CSRF verification failed | missing X-CSRFToken header or cookie not set | call the CSRF endpoint first; send credentials: "same-origin" |
403 with "Origin checking failed" | CSRF_TRUSTED_ORIGINS missing the HTTPS origin, or HTTPS not detected | add https://www.example.com; set SECURE_PROXY_SSL_HEADER |
| Users see old UI after deploy | index.html cached by browser or CDN | Cache-Control: no-cache on HTML; purge the CDN entry for / and /index.html |
| Some users get "Failed to fetch dynamically imported module" | old tab requests a chunk deleted by the new deploy | keep previous assets for a while; reload the page on chunk load errors |
Handling chunk load errors in the app
Even with careful asset retention, long-lived tabs can eventually request a missing chunk. Vite emits a vite:preloadError event on the window when a dynamic import fails, which lets you reload once:
window.addEventListener("vite:preloadError", (event) => {
const key = "reloaded-after-chunk-error";
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, "1");
window.location.reload();
}
});
The sessionStorage guard prevents an infinite reload loop if the asset is genuinely missing.
Authentication choices
For a same-origin SPA, Django's session authentication is usually the best fit:
- cookies are
HttpOnly, so JavaScript cannot read the session ID SameSite=Laxplus CSRF tokens protects state-changing requests- logout invalidates the session server-side
Token-based auth stored in localStorage is readable by any script that runs on the page, so an XSS bug exposes the token. Reserve token authentication for non-browser clients such as mobile apps and integrations.
Security headers for the SPA shell
Nginx serves index.html directly, so Django's SecurityMiddleware headers do not apply to it. Add the important ones in the SPA location:
location = /index.html {
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
Remember that add_header directives in a location replace those inherited from the server block, so repeat every header you need in each location that defines its own.
Next steps
- Review proxy settings in How to Configure Nginx for Django in Production.
- If the frontend runs on a different origin, see Fix CORS Errors with a Django API in Production.
- Debug
403responses with CSRF Verification Failed in Django Production.
FAQ
Why does refreshing a React route return 404 in production?
The server looks for a file matching the path and finds none. Add try_files $uri /index.html; to the SPA location so unknown paths return the app shell and the client-side router handles them.
Do I need django-cors-headers if React and Django share a domain?
No. CORS only applies to cross-origin requests. With the SPA and API on the same scheme, host, and port, the browser treats API calls as same-origin.
How do I stop users from getting an old React bundle after deploy?
Serve index.html with Cache-Control: no-cache and cache only content-hashed assets long-term. The browser then revalidates the HTML and discovers the new asset filenames.
Should Django or Nginx serve the React build?
Nginx is more efficient when you already run it. Serving the build from Django with WhiteNoise is reasonable on platforms where Django is the only process.