We ran CodeCeibe's main internal tool as two separate deploys for about fourteen months: a Django REST Framework API and a Next.js frontend talking to it over JSON. It looked like the "modern" architecture every blog post recommended. By month ten we were actively looking for a way out.
What was actually going wrong
Two deploy pipelines for one product. Every feature touched both repos: a serializer change in Django, a matching Zod schema and API client update in Next.js, two pull requests, two CI runs, and a window where the two services disagreed about the shape of a field. We shipped a bug where the API started returning null for an optional field while the frontend's TypeScript types said it couldn't be null -- it crashed in production for three hours before anyone noticed, because the two codebases had drifted.
Client/server state sync was the bigger problem. We used SWR for caching and optimistic updates, and more than once a user would mark an item as done, see the optimistic UI update instantly, then watch it revert a second later because a webhook-driven cache invalidation raced the mutation response. Debugging this meant reasoning about two caches (SWR's client cache and Django's database) plus a websocket channel telling the frontend when to revalidate. None of it was wrong exactly, it was just a lot of moving parts for what the feature actually needed.
Validation was duplicated outright. Every form had its rules written twice: once as a DRF serializer, once as a React Hook Form + Zod schema. They drifted within weeks of being written. A date-range check landed in the Zod schema but never made it into the serializer, so the API happily accepted data the frontend form would have rejected.
The migration
We moved back to a single Django app using django-htmx and server-rendered templates. The rule we used: if a view's job is to show the user the result of a state change, render HTML directly instead of returning JSON for a client to reconcile.
def mark_task_done(request, pk):
task = get_object_or_404(Task, pk=pk, owner=request.user)
task.status = Task.Status.DONE
task.save(update_fields=["status"])
if request.htmx:
return render(request, "tasks/_row.html", {"task": task})
return redirect("tasks:list")
The template fragment gets swapped into the DOM with hx-swap="outerHTML", so the row shown is always exactly what the database says, rendered server-side. There's no optimistic client state to desync because there's no separate client state to begin with.
What moved back server-side
- Validation: one Django form or serializer per model, period. No parallel Zod schema to keep in sync.
- Rendering: Django templates with
{% include %}partials instead of React components. Partial re-renders happen via htmx swapping a fragment, not a client-side virtual DOM diff. - State: lives in the database and the Django session, not a client store. A full page refresh and an htmx swap show the same thing because they run the same view code.
- "Real-time"-ish updates: polling with
hx-trigger="every 5s"for the few views that actually needed it, plus out-of-band swaps (hx-swap-oob) to update a notification badge from the response of an unrelated request. No websocket server left to operate.
What we gave up
Full page navigations are back for a handful of flows that used to be instant client-side route changes -- noticeable on a slow connection. A few interactions that felt snappy with optimistic UI, like a drag-and-drop reorder, needed more care to feel acceptable with a server round trip; we debounce and use hx-indicator instead of faking the result locally. The team also had to unlearn some React reflexes -- a few engineers initially reached for client state when an hx-target would have done the job.
One deploy pipeline, one CI run per pull request, a single place where validation lives, and a whole bug class -- client and server disagreeing about the shape of data -- that's now structurally impossible. For a CRUD-heavy internal tool, that trade was worth it.

