DEV Community

Cover image for Django + Next.js: The Integration Issues Nobody Warns You About
Josh Perspective
Josh Perspective

Posted on

Django + Next.js: The Integration Issues Nobody Warns You About

Pairing Django REST Framework with a Next.js frontend is a genuinely good combination and a mature, batteries-included backend with a modern React framework that handles routing, server components, and rendering strategy well. But the two frameworks make different assumptions about how an app runs, and those assumptions collide in ways that don't show up until you actually try to build and deploy, not just run npm run dev locally. Here's what I've run into building a Django + Next.js platform, and how to actually fix it.

The build fails, but only in production mode

The most disorienting version of this problem: your app runs perfectly with npm run dev, then npm run build fails with an error that seems to come from nowhere:

Error: useSearchParams() should be wrapped in a suspense boundary at page "/login".
Enter fullscreen mode Exit fullscreen mode

This happens because Next.js's App Router statically analyzes and pre-renders pages at build time wherever possible, and useSearchParams() depends on the actual URL at request time, something that doesn't exist yet during a static build. In dev mode, Next.js renders everything dynamically on demand, so this mismatch never surfaces. It only shows up when the build tries to determine what can be statically generated.

The fix: isolate the hook inside its own Suspense boundary

The fix isn't to avoid useSearchParams(), it's to isolate the component that uses it and wrap that specific component in <Suspense>, so Next.js knows exactly which part of the tree needs to wait for the client:

// Before - fails at build time
// app/login/page.tsx
"use client";
import { useSearchParams } from "next/navigation";

export default function LoginPage() {
  const searchParams = useSearchParams();
  const redirectTo = searchParams.get("redirect");
  return <form>{/* ...login form using redirectTo... */}</form>;
}
Enter fullscreen mode Exit fullscreen mode
// After - works
// app/login/page.tsx
import { Suspense } from "react";
import LoginForm from "./LoginForm";

export default function LoginPage() {
  return (
    <Suspense fallback={null}>
      <LoginForm />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/login/LoginForm.tsx
"use client";
import { useSearchParams } from "next/navigation";

export default function LoginForm() {
  const searchParams = useSearchParams();
  const redirectTo = searchParams.get("redirect");
  return <form>{/* ...login form using redirectTo... */}</form>;
}
Enter fullscreen mode Exit fullscreen mode

The page component becomes a thin wrapper: all hooks and JSX that depend on the search params move into the inner component, and only that inner component sits inside <Suspense>. This tells Next.js exactly which piece of the tree is allowed to render dynamically once the client has the actual URL, while the rest of the page can still be statically analyzed.

One thing worth knowing: adding export const dynamic = 'force-dynamic' to the page does not fix this on its own if the hook is called directly inside the page component. That flag changes rendering strategy for the route as a whole, but it doesn't address the specific requirement that useSearchParams() needs a Suspense boundary around the component that calls it. It's an easy dead end to go down expecting it to be the fix.

If you have multiple pages using search params login, password reset, email verification check each one individually. Fixing it in one place doesn't fix the pattern everywhere it's used; each page needs its own properly isolated component.

The deeper mismatch: static export vs. a live Node process

The Suspense issue is a symptom of a bigger thing worth understanding upfront: if you're using the App Router with server components or a middleware.ts file, you cannot use Next.js's static export mode (next export or output: 'export'). Those features require a live Node.js process at runtime to handle server-side rendering and middleware logic there's no way to pre-render them into static files ahead of time.

This matters for how you think about deployment: a Next.js frontend using these features isn't a static site you can drop on any CDN or static host. It needs a Node server running, which changes your infrastructure story you're deploying a running service, not a folder of HTML/CSS/JS.

Local development: two processes, one docker-compose (mostly)

A practical pattern that's worked well: run the Django stack (API, Postgres, Redis, Celery, Celery Beat) via Docker Compose, and run the Next.js frontend separately with npm run dev, rather than trying to force everything into one compose file.

# docker-compose.yml — backend only
services:
  web:
    build: ./backend
    ports:
      - "8000:8000"
    depends_on:
      - db
      - redis
  db:
    image: postgres:16
  redis:
    image: redis:7
  celery:
    build: ./backend
    command: celery -A config worker -l info
  celery-beat:
    build: ./backend
    command: celery -A config beat -l info
Enter fullscreen mode Exit fullscreen mode

The Next.js dev server has fast refresh and its own hot-reload behavior that doesn't always play nicely inside Docker (file-watching across a mounted volume can be flaky depending on your OS), so keeping it as a separate native process during local development is usually less friction than containerizing it too early.

Unifying under one entry point for production

Running two separate services (Django on one port, Next.js on another) works for local development, but production usually benefits from unifying them behind a single entry point most commonly with nginx as a reverse proxy, routing /api/* to Django and everything else to Next.js:

server {
    listen 80;

    location /api/ {
        proxy_pass http://django:8000;
    }

    location / {
        proxy_pass http://nextjs:3000;
    }
}
Enter fullscreen mode Exit fullscreen mode

This avoids CORS complexity entirely in production (since both are served from the same origin) and gives you one place to handle TLS termination, rather than configuring it separately for each service.

A short checklist

  • Every use of useSearchParams() (and similarly, usePathname() in some cases) is isolated inside its own component wrapped in <Suspense>, not called directly in a page component
  • export const dynamic = 'force-dynamic' is not relied on as a substitute for proper Suspense boundaries
  • Static export is ruled out as an option if you're using server components, the App Router with dynamic routes, or middleware
  • Local dev keeps the Next.js dev server as a native process rather than forcing it into Docker alongside the backend, unless you've confirmed file-watching works reliably in your setup
  • Production unifies both services behind a reverse proxy (nginx or similar) to avoid CORS and simplify TLS

The Django + Next.js combination is a strong stack once these seams are handled, but the two frameworks' differing assumptions about rendering and runtime environment are exactly the kind of thing that looks fine until you try to actually ship worth knowing about before you hit them at 11pm the night before a deploy, not after.

Top comments (0)