HTMX y Go en 2026: aplicaciones web sin el peso de un SPA

Montar una aplicación web con React, Vue o Angular implica tomar un buen puñado de decisiones antes de escribir la primera línea de lógica: qué bundler usar, cómo gestionar el estado, cómo estructurar los endpoints JSON, cómo hidratar los componentes en el cliente. Para una app de producción con cientos de miles de usuarios y un equipo frontend dedicado, esa complejidad tiene sentido. Para un dashboard interno, un CRUD de gestión o una herramienta de backoffice, muchas veces no.

HTMX parte de una idea distinta: el servidor devuelve HTML, no JSON. El cliente no necesita saber nada de estado, porque cada respuesta ya llega renderizada. Puedes hacer actualizaciones parciales de la página, reemplazar un div concreto, añadir filas a una tabla o actualizar un contador sin recargar nada, y sin escribir JavaScript a mano. El comportamiento está en atributos HTML.

Go encaja aquí de forma natural. Es un servidor rápido, tiene un motor de templates en la stdlib, compila a un binario único y no necesita nada en tiempo de ejecución. Si ya usas Go para la API, añadir HTMX es prácticamente gratis.

Cómo funciona HTMX

La idea básica es sencilla. Añades atributos hx-* a cualquier elemento HTML y HTMX se encarga de hacer la petición y actualizar el DOM con la respuesta:

<button hx-post="/like" hx-target="#resultado" hx-swap="outerHTML">Me gusta</button>
<div id="resultado"></div>

Al hacer clic, HTMX lanza un POST a /like y reemplaza el elemento #resultado con el HTML que devuelve el servidor. Sin fetch manual, sin actualizar estado, sin re-renderizar componentes. El servidor controla lo que se muestra.

Los atributos que más vas a usar son:

  • hx-get, hx-post, hx-put, hx-delete: el método HTTP y la URL destino
  • hx-target: el selector CSS del elemento que se va a actualizar
  • hx-swap: cómo se inserta la respuesta (innerHTML, outerHTML, beforeend, afterbegin...)
  • hx-trigger: el evento que dispara la petición (por defecto es click o change según el elemento)

Con eso cubres el 90% de los casos. Para el resto hay extensiones opcionales, como SSE o WebSockets, que veremos más adelante.

html/template: el motor de templates de la stdlib

Go incluye html/template desde el primer día, sin dependencias externas. Lo más importante es que autoescapa el HTML: si pasas un string con caracteres peligrosos, los escapa solo. No tienes que acordarte de hacerlo.

tmpl := template.Must(template.ParseFiles("templates/base.html", "templates/tasks.html"))

http.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
    tasks := db.GetTasks()
    tmpl.ExecuteTemplate(w, "tasks.html", tasks)
})

La sintaxis del template es limpia: {{.Campo}} para acceder a un campo, {{if .Condicion}} para condicionales, {{range .Items}} para iterar. Puedes componer templates con {{block}} y {{template}}.

El inconveniente es que no hay type checking en tiempo de compilación. Si pasas una struct con un campo Name y en el template escribes {{.Nombre}}, el error lo ves en runtime, no antes. Para proyectos pequeños es asumible. Para proyectos más grandes, hay una opción mejor.

templ: templates tipados para Go

templ resuelve exactamente ese problema. Es una librería que define un lenguaje de templates compilados: escribes archivos .templ y el compilador genera código Go a partir de ellos. Si el tipo que pasas no coincide con lo que el template espera, el compilador te lo dice antes de ejecutar nada.

// archivo: components/task.templ
templ TaskItem(task Task) {
    <li id={ fmt.Sprintf("task-%d", task.ID) }>
        <span>{ task.Title }</span>
        <button hx-delete={ fmt.Sprintf("/tasks/%d", task.ID) }
                hx-target={ fmt.Sprintf("#task-%d", task.ID) }
                hx-swap="outerHTML">
            Borrar
        </button>
    </li>
}

Para generar el código Go ejecutas templ generate. Puedes integrarlo en el proceso de build o usar el modo watch durante el desarrollo. En 2026, para proyectos nuevos con Go y HTML, templ es la opción habitual. La seguridad contra XSS sigue siendo automática, igual que con html/template.

Ejemplo completo: lista de tareas con HTMX

Veamos cómo quedaría un CRUD básico. Tres handlers, nada más:

// GET /tasks — devuelve la lista completa
http.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
    tasks := db.GetAll()
    components.TaskList(tasks).Render(r.Context(), w)
})

// POST /tasks — crea una tarea y devuelve solo el nuevo item
http.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
    title := r.FormValue("title")
    task := db.Create(title)
    components.TaskItem(task).Render(r.Context(), w)
})

// DELETE /tasks/{id} — borra y devuelve un elemento vacío
http.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.Atoi(r.PathValue("id"))
    db.Delete(id)
    w.WriteHeader(http.StatusOK) // HTMX reemplaza el elemento con nada
})

En el HTML, el formulario para añadir una tarea es así:

<form hx-post="/tasks" hx-target="#task-list" hx-swap="beforeend">
    <input type="text" name="title" placeholder="Nueva tarea">
    <button type="submit">Añadir</button>
</form>
<ul id="task-list">
    @for _, t := range tasks {
        @components.TaskItem(t)
    }
</ul>

Sin React. Sin JSON. Sin estado en el cliente. Cada acción devuelve un fragmento HTML que HTMX inserta donde le indicas. El servidor es la fuente de verdad en todo momento.

Server-Sent Events: actualizaciones en tiempo real

Para dashboards con datos que cambian solos o feeds de actividad, HTMX tiene una extensión de SSE. Incluyes la extensión y añades unos atributos al elemento que quieres actualizar:

<div hx-ext="sse" sse-connect="/events" sse-swap="message">
    Esperando datos...
</div>

En el servidor, un endpoint SSE en Go es directo. Cada conexión es una goroutine que escribe en el ResponseWriter con el formato correcto:

http.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, _ := w.(http.Flusher)
    for {
        select {
        case <-r.Context().Done():
            return
        case data := <-updates:
            fmt.Fprintf(w, "data: %snn", data)
            flusher.Flush()
        }
    }
})

Cada cliente SSE es una goroutine ligera. Go maneja bien cientos de conexiones abiertas simultáneas sin que el servidor se resienta. Para notificaciones, métricas en directo o feeds de actividad, esto cubre perfectamente lo que de otro modo requeriría WebSockets con bastante más código.

HTMX + Go frente a React + API

No hay una respuesta universal, pero sí hay casos donde cada enfoque gana con claridad.

Con HTMX y Go te ahorras el bundler, el state management, la capa JSON y la hidratación del cliente. El binario de Go incluye todo: servidor, templates, assets estáticos. El despliegue es copiar un fichero. XSS protegido por defecto. Si el equipo sabe Go y la app es principalmente formularios, listados y acciones CRUD, la elección es obvia.

React tiene más sentido cuando la interfaz es muy interactiva a nivel local (arrastrar y soltar, editores de texto, visualizaciones complejas), cuando hay un equipo frontend especializado ya trabajando con ese stack, o cuando la app necesita funcionar offline. Para esos casos, la complejidad está justificada.

El criterio práctico: si casi toda la interactividad consiste en "el usuario hace algo, el servidor responde con datos actualizados", HTMX + Go es más rápido de desarrollar, más fácil de mantener y tiene menos superficie de error. Como se explica en Go en el backend web en 2025, el lenguaje lleva años consolidándose en aplicaciones que priorizan la simplicidad operativa.

El stack completo en producción

HTMX cubre las peticiones al servidor, pero hay cosas que no necesitan red: abrir un dropdown, mostrar un tooltip, validar un campo antes de enviar. Para eso viene bien Alpine.js, que funciona igual que HTMX pero para estado local. Los dos conviven sin problemas porque ninguno toca el DOM del otro.

Para el CSS, TailwindCSS encaja bien con esta filosofía. Generas el fichero de producción con npx tailwindcss build y el resultado es un CSS estático que sirves directamente. Sin Node.js en producción, sin build pipeline en el servidor.

El stack completo queda así:

  • Go + net/http: servidor y lógica de negocio
  • templ: templates con type checking
  • HTMX: peticiones parciales sin JavaScript manual
  • Alpine.js: microinteractividad local
  • TailwindCSS: estilos utilitarios generados en build time

En producción, un binario Go sirve todo. Sin node_modules, sin runtime de JavaScript, sin proceso separado para el frontend. Si quieres entender por qué Go es la opción natural para aplicaciones web ligeras, la parte de despliegue lo deja bastante claro: un solo fichero, sin dependencias externas, que arranca en milisegundos.

Para proyectos donde la complejidad de un SPA no aporta nada, HTMX + Go es hoy una de las combinaciones más eficientes que puedes montar.

Imagen: Pexels / Muhammed Ensar

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP