2026-04-26 | PreviewProof Team
Preview-Ready Django + Next.js: A Decoupled Headless Stack
Django as a headless API and Next.js as a separately deployed frontend has become one of the more common architectures in 2026 — particularly for teams in regulated industries where Django’s mature auth and admin pair well with a modern frontend. It also happens to be one of the most preview-environment-unfriendly architectures by default.
Two services. Two languages. Two build systems. Two deployment models. Two sets of environment variables. A coordination problem between the frontend’s preview and the backend’s preview that doesn’t exist in monolithic stacks. Most preview tooling handles each half well in isolation; few handle both halves together correctly.
This post covers the configuration. The reference stack is Django REST Framework (or Django Ninja) on the backend, Next.js 14 with App Router on the frontend, Postgres 16 as the database. The patterns adapt to GraphQL backends and to Pages Router Next.js with minor changes.
Architecture as a first-class concern
Three coordination questions matter:
- How does the frontend find the backend’s URL? Build-time vs runtime injection — see environment variables for ephemeral previews.
- How do versions stay in sync? A frontend PR that depends on backend changes should preview against those changes, not against
main. - How is data set up? The backend’s seed script runs in the backend container; the frontend tests against it. The frontend never touches the database directly.
The cleanest answer to all three is to deploy both services together as part of the same preview, behind the same public hostname. That’s what the Compose configuration below does.
docker-compose.yml: local development
services: api: build: ./backend command: python manage.py runserver 0.0.0.0:8000 environment: DJANGO_SETTINGS_MODULE: app.settings.dev DATABASE_URL: postgres://postgres:postgres@db:5432/app depends_on: db: { condition: service_healthy }
web: build: ./frontend command: npm run dev environment: NEXT_PUBLIC_API_URL: http://localhost:8000 INTERNAL_API_URL: http://api:8000 ports: - "3000:3000" depends_on: - api
db: image: postgres:16 environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: app healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5docker-compose.yml: preview-ready
Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to built images for both services, env-var-injected hostnames, and preview-specific settings on each side:
services: api: image: ghcr.io/myorg/api:${GIT_SHA} command: ./bin/api-entrypoint.sh environment: DJANGO_SETTINGS_MODULE: app.settings.preview DATABASE_URL: postgres://postgres:postgres@db:5432/app_preview ALLOWED_HOSTS: ${PREVIEW_HOST},api CSRF_TRUSTED_ORIGINS: https://${PREVIEW_HOST} CORS_ALLOWED_ORIGINS: https://${PREVIEW_HOST} SECRET_KEY: ${DJANGO_SECRET_KEY}
web: image: ghcr.io/myorg/web:${GIT_SHA} command: node server.js environment: NODE_ENV: production INTERNAL_API_URL: http://api:8000 NEXT_PUBLIC_API_URL: https://${PREVIEW_HOST}/api NEXTAUTH_URL: https://${PREVIEW_HOST} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}Two API URLs again, same as the Next.js + separate backend post. INTERNAL_API_URL is for Next.js server-side code (route handlers, server components), reaching the Django container at its internal hostname api:8000. NEXT_PUBLIC_API_URL is what the browser sees — /api on the same public hostname, proxied through Next.js so CORS becomes a non-issue.
ALLOWED_HOSTS: ${PREVIEW_HOST},api is worth pausing on. Django needs to allow both the public preview hostname and the internal api hostname (because Next.js’s server-side code calls Django at http://api:8000, and Django checks the Host header). Forgetting the second one produces 400 Bad Request errors that look mysterious until you trace the request path.
API URL injection: the Next.js proxy route
Rather than configuring CORS on Django to allow the dynamic preview origin, route browser API calls through Next.js itself.
import { NextRequest, NextResponse } from "next/server";
const INTERNAL = process.env.INTERNAL_API_URL!;
async function proxy(req: NextRequest, params: { path: string[] }) { const url = `${INTERNAL}/${params.path.join("/")}${req.nextUrl.search}`; const init: RequestInit = { method: req.method, headers: req.headers, body: ["GET", "HEAD"].includes(req.method) ? undefined : await req.text(), }; const res = await fetch(url, init); return new NextResponse(res.body, { status: res.status, headers: res.headers });}
export const GET = (r: NextRequest, { params }) => proxy(r, params);export const POST = (r: NextRequest, { params }) => proxy(r, params);export const PUT = (r: NextRequest, { params }) => proxy(r, params);export const DELETE = (r: NextRequest, { params }) => proxy(r, params);The browser calls https://pr-123.previews.example.com/api/users/. Next.js receives it, forwards to http://api:8000/users/, and returns the response. CORS doesn’t apply because everything is same-origin from the browser’s view. Cookies (including Django session cookies for authenticated routes) work without SameSite=None gymnastics.
CORS for direct API access
Even with the proxy pattern, you may want direct API access for non-browser clients (mobile apps, integration tests, the Django admin). Configure django-cors-headers:
CORS_ALLOWED_ORIGINS = [o for o in os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",") if o]CORS_ALLOWED_ORIGIN_REGEXES = [ r"^https://[a-z0-9-]+\.previews\.example\.com$",]CORS_ALLOW_CREDENTIALS = TrueWildcarding *.previews.example.com once at platform level prevents the “every new PR needs CORS reconfigured” tax.
Coordinated seeding
The backend owns data. The frontend tests against whatever data the backend produces. Two patterns:
Single-PR coordination. If your repo is a monorepo with both backend/ and frontend/ directories, a single PR changing both means the preview includes both changes against a single seeded database. Cleanest by far. See monorepo vs polyrepo previews.
Cross-repo coordination. Frontend in one repo, backend in another. The frontend PR includes a small manifest (something like preview-overrides.yaml) declaring “this PR depends on backend PR #515” and the preview platform builds both before deploying. Without explicit declaration, default to backend main and accept that some frontend PRs will look broken until the backend catches up.
The Django entrypoint runs migrations and a preview-specific seed:
#!/usr/bin/env bashset -epython manage.py migrate --noinputpython manage.py collectstatic --noinputpython manage.py loaddata fixtures/preview_seed.jsonpython manage.py seed_demo_dataexec gunicorn app.wsgi:application -b 0.0.0.0:8000 -w 3For the deeper Django seeding pattern, see preview-ready Django + Postgres + Celery. The seed shape is the same — factory_boy for richer data, get_or_create for idempotency.
Auth across the proxy boundary
Django session auth works through the Next.js proxy. The browser’s session cookie (sessionid) is sent on requests to /api/*, Next.js forwards it to Django, Django validates it. No special configuration.
Token auth (DRF’s TokenAuthentication or JWT) also works without changes. The Authorization header passes through the proxy untouched.
OAuth callbacks need to point at Django’s URL pattern (/auth/callback/...). Because the proxy sits at /api/, you’ll route the callback path explicitly:
// Same proxy pattern as above, forwards to http://api:8000/auth/callback/...See OAuth callback URLs in ephemeral previews for the wildcard-callback pattern at the OAuth provider level.
Env template
# .env.preview.example for backendDJANGO_SETTINGS_MODULE=app.settings.previewSECRET_KEY= # generated per previewDATABASE_URL= # injectedALLOWED_HOSTS= # injected: pr-123.previews.example.com,apiCSRF_TRUSTED_ORIGINS= # injected: https://pr-123.previews.example.com
# .env.preview.example for frontendINTERNAL_API_URL=http://api:8000NEXT_PUBLIC_API_URL= # injected: https://pr-123.previews.example.com/apiNEXTAUTH_URL= # injected: https://pr-123.previews.example.comNEXTAUTH_SECRET= # generated per previewThe Django + Next.js headless stack works well in previews once you commit to deploying both services together behind a single hostname. The proxy-route pattern eliminates CORS as a concern, the per-PR ALLOWED_HOSTS injection keeps Django happy, and a single seed run on the backend produces consistent data for the frontend to test against. The configuration above is portable — you can run it under whatever preview tool, CI orchestration, or self-hosted setup fits your team.
If you’d rather not script the per-PR coordination yourself, PreviewProof supports Compose-shaped multi-service Django + Next.js stacks directly, including the proxy pattern above and per-PR ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS injection. It’s most relevant for teams in regulated industries where the audit trail on top of these previews — who reviewed what, when, with what evidence — is part of what makes the architecture justifiable.