Cómo volvimos a fusionar una Django REST API y Next.js en una sola app Django con htmx



Durante catorce meses, la herramienta interna principal de CodeCeibe vivió repartida en dos despliegues: una API con Django REST Framework por un lado y un frontend en Next.js que le hablaba por JSON por el otro. Parecía la arquitectura "moderna" que recomendaba cualquier artículo del momento. Para el mes diez ya estábamos buscando activamente la salida.

Dónde dolía de verdad

Dos pipelines de despliegue para un solo producto. Cualquier funcionalidad tocaba los dos repositorios: un cambio en un serializer de Django exigía actualizar el esquema Zod y el cliente de API en Next.js, dos pull requests, dos builds de CI, y una ventana en la que los dos servicios no se ponían de acuerdo sobre la forma de un campo. Llegamos a desplegar un bug en el que la API empezó a devolver null en un campo opcional mientras los tipos de TypeScript del frontend decían que eso no podía pasar -- estuvo roto en producción tres horas antes de que alguien lo notara, porque los dos códigos se habían desincronizado.

La sincronización de estado cliente/servidor era el problema más grande. Usábamos SWR para cache y actualizaciones optimistas, y más de una vez un usuario marcaba una tarea como hecha, veía el cambio aplicado al instante y un segundo después lo veía revertirse, porque una invalidación de caché disparada por un webhook llegaba antes que la respuesta de la mutación. Depurar esto significaba razonar sobre dos cachés (la de SWR en el cliente y la base de datos de Django) más un canal de websocket que le avisaba al frontend cuándo revalidar. No es que estuviera mal hecho, es que había demasiadas piezas moviéndose para lo que la funcionalidad realmente necesitaba.

La validación estaba lisa y llanamente duplicada. Cada formulario tenía sus reglas escritas dos veces: una como serializer de DRF, otra como esquema de React Hook Form + Zod. Se desincronizaban a las pocas semanas de escribirse. Una validación de rango de fechas se añadió en el esquema Zod pero nunca llegó al serializer, así que la API aceptaba sin problema datos que el formulario del frontend habría rechazado.

La migración

Volvimos a una sola aplicación Django con django-htmx y plantillas renderizadas en el servidor. La regla que usamos: si una vista existe para mostrarle al usuario el resultado de un cambio de estado, se renderiza HTML directamente en vez de devolver JSON para que el cliente lo reconcilie.

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")

El fragmento de plantilla reemplaza al elemento con hx-swap="outerHTML", así que la fila que se ve siempre es exactamente lo que dice la base de datos, renderizado en el servidor. No hay estado optimista de cliente que se desincronice porque no hay un estado de cliente separado para empezar.

Dos pipelines de despliegue convertidos en uno Antes: dos pipelines Frontend en Next.js (Node, Vercel) API REST en Django (gunicorn, CORS) JSON deploy: npm build deploy: docker + gunicorn Después: un solo pipeline Django + htmx (plantillas en servidor) deploy: una sola imagen docker

Qué volvió al servidor

  • Validación: un único formulario o serializer de Django por modelo, y punto. Sin un esquema Zod paralelo que mantener sincronizado.
  • Renderizado: plantillas de Django con fragmentos {% include %} en vez de componentes React. Los re-renderizados parciales pasan por un swap de htmx sobre un fragmento, no por un diff de virtual DOM en el cliente.
  • Estado: vive en la base de datos y en la sesión de Django, no en un store de cliente. Un refresco completo de página y un swap de htmx muestran lo mismo porque ejecutan el mismo código de vista.
  • Actualizaciones "en vivo": polling con hx-trigger="every 5s" para las pocas vistas que de verdad lo necesitaban, más swaps fuera de banda (hx-swap-oob) para actualizar un contador de notificaciones desde la respuesta de una petición que no tenía nada que ver. Sin servidor de websockets que operar.

Qué perdimos

Volvieron las navegaciones de página completa en un puñado de flujos que antes eran cambios de ruta instantáneos en el cliente -- se nota en una conexión lenta. Algunas interacciones que se sentían ágiles con UI optimista, como reordenar con drag-and-drop, necesitaron más cuidado para sentirse aceptables con un viaje de ida y vuelta al servidor; usamos debounce y hx-indicator en vez de simular el resultado localmente. El equipo también tuvo que desaprender algunos reflejos de React -- algunos ingenieros al principio buscaban estado de cliente donde un hx-target habría bastado.

Un solo pipeline de despliegue, un solo build de CI por pull request, un único lugar donde vive la validación, y una clase entera de bugs -- cliente y servidor en desacuerdo sobre la forma de los datos -- que ahora es estructuralmente imposible. Para una herramienta interna con mucho CRUD, el cambio valió la pena.

Dejar un comentario

Los comentarios anónimos se publican tras ser revisados.