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/42 returns a 404 from Nginx or Django
  • the SPA's index.html gets cached, so users keep loading an old bundle after a deploy
  • /static/ is claimed by both Django's collectstatic output 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 POST requests 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.

SPA and API on one domainBrowserone originNginxroutes by path/api/ and /admin/proxied to Djangoindex.htmlSPA fallbackOne origin removes CORS entirely and keeps session cookies simple.

Quick answer

  1. Build the SPA to a directory such as /srv/myapp/frontend/dist, with hashed asset filenames under /assets/.
  2. In Nginx, proxy /api/ and /admin/ to Gunicorn, and serve Django's collected files from /static/.
  3. Serve the SPA from location / with try_files $uri /index.html; so client-side routes load the app.
  4. Cache /assets/ for a year as immutable, and send Cache-Control: no-cache for index.html.
  5. Keep API 404s as JSON by never letting /api/ fall back to index.html.
  6. Use Django session auth: fetch a CSRF cookie, then send X-CSRFToken on unsafe requests.
  7. Verify deep links, cache headers, and a POST with CSRF.

Step-by-step solution

1. Decide the URL layout

PathServed by
/api/Django (Gunicorn)
/admin/Django (Gunicorn)
/static/Nginx, from Django's STATIC_ROOT
/assets/Nginx, from the SPA build
everything elseNginx, SPA index.html fallback
one domain, clear path ownershipServed byCaching/index.html via Nginx!no-cache/assets/Nginx, hashed files✓1 year, immutable/api/Djangonone/admin/Djangonone/static/Nginx, Django static30 daysSame origin means no CORS and session cookies just workdjango-deployment.com
Keep API routes under one prefix so Nginx rules stay simple.

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;
    }
}
the SPA locationslocation /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; }1231A missing asset is a real 404, not the app.2HTML revalidated: new deploys show at once.3Client routes like /orders/42 load the app./api/ and /admin/ locations proxy to Django before these matchdjango-deployment.com
Returning index.html for missing assets causes confusing chunk errors.

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/42 loads the app and React Router takes over.
  • The internal redirect lands in location = /index.html, which adds Cache-Control: no-cache. Browsers revalidate the HTML on every load, so a new deploy's asset references are picked up immediately.
  • /assets/ returns a real 404 for missing files instead of falling back to HTML. Otherwise a missing JavaScript chunk would return index.html with a 200 status 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"})
CSRF for a same-origin SPAApp loadsfrom index.htmlGET /api/csrf/ensure_csrf_cookieRead csrftoken cookiein JavaScriptPOST with X-CSRFTokencredentials: same-originDjango acceptssession + token matchCSRF_COOKIE_HTTPONLY must stay False for this patterndjango-deployment.com
The default CSRF cookie settings already allow this.

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:

  1. Deploy the backend and run migrations; keep the API backward compatible with the old frontend.
  2. Upload the new frontend assets. Old index.html copies in open tabs still reference old hashed files, so keep the previous release's assets available for a while (for example by merging asset directories rather than deleting them immediately).
  3. Switch index.html to the new build.
deploy without broken chunks1Upload new assetskeep the old ones2Deploy the APIbackward compatible3Switch index.htmlpoints at new assets4Users reload into new versionold tabs still work5Prune old assets laterafter a day or twoDeleting old chunks at deploy time breaks open browser tabsdjango-deployment.com
The API must serve both old and new frontends for a while.

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 200 with text/html
  • index.html responses have Cache-Control: no-cache
  • assets have a one-year immutable cache header
  • missing assets and unknown API routes return 404
  • a logged-in POST succeeds with the CSRF header and fails with 403 without 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

SymptomCauseFix
Blank page, console shows Unexpected token '<'a JavaScript file request returned index.htmlensure /assets/ uses try_files $uri =404; check base in Vite config
/admin/ shows the React appcatch-all location matched before the admin proxydefine /admin/ location; confirm Nginx config reloaded
API POST returns 403 CSRF verification failedmissing X-CSRFToken header or cookie not setcall the CSRF endpoint first; send credentials: "same-origin"
403 with "Origin checking failed"CSRF_TRUSTED_ORIGINS missing the HTTPS origin, or HTTPS not detectedadd https://www.example.com; set SECURE_PROXY_SSL_HEADER
Users see old UI after deployindex.html cached by browser or CDNCache-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 deploykeep previous assets for a while; reload the page on chunk load errors
SPA problems and causesSymptomUsual cause404 on page refreshno try_files fallback to index.htmlBlank page after deploycached index.html, deleted chunksChunkLoadErrorold tab requests a pruned file403 CSRF on POSTX-CSRFToken header missingMost SPA deploy bugs are caching bugsdjango-deployment.com
Reloading the page once on ChunkLoadError fixes it for users.

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=Lax plus 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

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.

Keep going