Next.js + Django on a Single Origin, No CORS Headaches
The moment you run a Next.js frontend and a Django backend as separate services, you run into CORS (Cross-Origin Resource Sharing) issues. A frontend on localhost:3000 calling an API on localhost:8000 looks like a different origin to the browser, which means extra header configuration. In production this gets worse: CORS settings, cookie SameSite behavior, and managing two separate domains or subdomains.
The fix: single origin via nginx
On a client project I built, I solved this at the root by making nginx the single entry point — serving both Next.js and Django from the same origin. The block below is a trimmed-down version of the idea; the real config has more in it, which I cover further down.
server {
listen 80;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
}
location /admin-panel-path/ {
proxy_pass http://backend:8000;
}
location / {
proxy_pass http://frontend:3000;
}
}From the browser's perspective there's just one domain: anything starting with /api goes to Django, the admin path goes to the Django admin panel, and everything else goes to Next.js. No CORS headers needed, because the browser sees everything coming from the same origin.
What the production config adds
A real production config differs from the sketch above in ways that matter:
- TLS terminates at nginx. Port 80 only answers the certificate challenge and redirects to HTTPS. One certificate covers everything, which is where the "no second SSL cert" benefit comes from.
- Forwarded headers on every proxied location.
Host,X-Real-IP,X-Forwarded-ForandX-Forwarded-Protoare all passed to the backend. Django needs the last one to know the original request was HTTPS. - Static and media files skip both apps. Collected Django static files and uploaded media are served by nginx straight from shared volumes, with long cache headers. Next's own hashed assets under
/_next/static/get an immutable cache header. - Rate limits at the proxy. nginx's
limit_reqcan give different locations different limits. Treat it as a coarse flood barrier only; real abuse protection belongs in application-level throttles (DRF has them built in) on the endpoints that need it. - Upstream blocks name each service once so every location refers to it by name.
It's also worth keeping a separate, simpler nginx config for local development on one HTTP port, so the browser sees a single origin there too.
Auth cookies are where single origin pays off
This is the part that makes the single origin more than a CORS shortcut. A Django backend can authenticate with JWTs (for example rest_framework_simplejwt) and put them in httpOnly cookies instead of localStorage. Because the browser sees one origin, a plain SameSite=Lax cookie is enough — no SameSite=None, no withCredentials dance, no cookie domain juggling.
# simplified example
response.set_cookie(
ACCESS_COOKIE_NAME,
access,
path="/",
httponly=True,
secure=COOKIE_SECURE,
samesite="Lax",
)
response.set_cookie(
REFRESH_COOKIE_NAME,
str(refresh),
path="/<auth-endpoints>/", # only sent to refresh/logout
httponly=True,
secure=COOKIE_SECURE,
samesite="Lax",
)Keep the access cookie short-lived; it goes with every request. Give the refresh cookie a longer lifetime and restrict its path to the auth endpoints, so it is not sent on ordinary API calls. simplejwt can rotate refresh tokens and blacklist the old ones, and both are worth turning on. A small custom authentication class can read the token from the cookie when there is no Authorization header. Drive the Secure flag from a setting that is on in production and off only for plain-HTTP local development.
The gotcha: Next's server bypasses nginx
Here is the failure that cost me the most time. In production Django runs with SECURE_SSL_REDIRECT on and SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"), so it trusts the proxy's header to decide whether a request was HTTPS. Browser requests pass through nginx, which sets that header.
But when a Server Component calls Django, the request comes from the Node.js process and goes straight to the backend's internal address (an INTERNAL_API_URL setting) — it never touches nginx. No header means Django thinks the request is plain HTTP and answers with a redirect to HTTPS. The Node fetch then tries a TLS handshake against a gunicorn that only speaks plain HTTP, and the call dies on a connect timeout.
The nasty part is how it looks from outside: the site stays up, because ISR keeps serving previously cached pages. Only pages whose cache is being regenerated silently fall back to default content — dynamic entries can quietly drop out of a generated sitemap, for example. The fix is to send the header yourself from every server-side fetch:
function internalFetch(path: string, init?: RequestInit) {
return fetch(`${INTERNAL_API_URL}${path}`, {
...init,
headers: { ...init?.headers, "X-Forwarded-Proto": "https" },
});
}I now route every server-side request through one wrapper like this, so nobody can forget it.
Hiding the admin path
Using Django admin's default /admin/ path makes it the first target for bot brute-force attempts. Instead, I use an unguessable path:
# config/urls.py
urlpatterns = [
path("unguessable-admin-path/", admin.site.urls),
# ...
]This isn't a real security layer, but it's a cheap, effective filter against the bulk of automated scanning bots. It only works if real protections stand behind it: strong passwords, rate limiting and, where you can, an extra factor.
Bringing it together with Docker Compose
In a real deployment the compose file usually holds more than the two apps, such as a database, a cache or task broker, and background workers. nginx publishes the ports; the app containers are only reachable over the internal network. Here is the minimal three-service version of the idea. For a full production setup with a database and secrets management on top of this, see a production Docker Compose setup for Next.js + Django:
services:
frontend:
build: ./frontend
expose:
- "3000"
backend:
build: ./backend
expose:
- "8000"
nginx:
image: nginx:alpine
ports:
- "80:80"
depends_on:
- frontend
- backendResult
This approach eliminates CORS configuration, cookie domain issues, and the need to manage two separate SSL certificates. The same architecture works in both development and production, which significantly cuts down on "it worked locally" surprises. The cost is that the shared nginx becomes the one place where routing, TLS, caching and rate limits all live, and that server-side requests need the care described above. If you're looking for a server to run this on, deploying Django on AWS Lightsail walks through setting up nginx and HTTPS from a blank server.
One more caveat: the shared origin only helps requests the browser makes. A fetch inside a Server Component runs on your Node.js server, so the browser's auth cookie (a sessionid with Django sessions, or the access cookie in the JWT setup above) doesn't go with it and Django treats the user as anonymous — why Django sessions are empty in Server Components shows how to forward it. If your server-side calls only read public content, none of them needs the cookie.