- Manteniendo el renderizado del lado del servidor basado en
html/template de Go, se agregan interacciones dinámicas con HTMX, y desde un único renderizador se devuelven de forma selectiva páginas completas o fragmentos HTML.
- Se divide el marcado en
base.tmpl, plantillas de páginas y fragmentos reutilizables, y se aprovechan embed.FS de Go 1.16 y un conjunto compartido de plantillas para simplificar la estructura de despliegue y renderizado.
- Según
HX-Request, los resultados de búsqueda se devuelven como página completa o como filas de tabla, y se evitan errores de caché y restauración al volver atrás con Vary: HX-Request y historyRestoreAsHxRequest: false.
- Al mover una solicitud HTMX a otra página, se usan
HX-Redirect y una respuesta 2xx en lugar de un 3xx normal, y se controla el alcance del reemplazo y la visualización de errores con responseHandling para 204, 422, 4xx y 5xx.
- Se toma como punto de partida una configuración que desactiva la caché de almacenamiento local y la herencia de atributos, y que define un tiempo límite para las solicitudes; cuando la escala crece, se pueden agregar plantillas de layout intermedio para admitir incluso estructuras de pantalla separadas, como áreas de administración.
Por qué combinar el renderizado de servidor de Go con HTMX
- HTMX permite agregar interacciones dinámicas con muy poco JavaScript, manteniendo la consistencia y la seguridad que ofrece el renderizado HTML del lado del servidor de
html/template en Go.
- La implementación aborda tres ejes:
- Una estructura de plantillas que admite tanto páginas completas como respuestas HTML parciales.
- Redirecciones y manejo de errores en un entorno HTMX.
- Configuración que cambia los valores predeterminados de HTMX y las razones para hacerlo.
- Se empieza con una funcionalidad básica que reemplaza un botón por una imagen, y luego se amplía a una pantalla de búsqueda que filtra una lista mientras se ingresa un nombre de usuario o correo electrónico.
- El mismo patrón de plantillas se puede aplicar no solo a HTMX, sino también a herramientas HTML-over-the-wire como Unpoly y Hotwire.
- Si no conoces el uso básico de HTMX, conviene revisar primero la documentación oficial.
Directorio del proyecto y configuración de archivos estáticos
- El proyecto se divide en las siguientes áreas según su función:
assets/html/base.tmpl: esqueleto HTML compartido por todas las páginas.
assets/html/pages/: contenido de páginas individuales.
assets/html/partials/: fragmentos HTML reutilizados en varios lugares.
assets/static/css, assets/static/img, assets/static/js: recursos estáticos.
cmd/web/: inicio del servidor, handlers y código del renderizador HTML.
- El esqueleto básico se genera con los siguientes comandos:
go mod init example.com/htmx
mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web
touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
- HTMX también se puede instalar vía CDN o NPM, pero normalmente se usa el enfoque de descargar una copia y servirla directamente como archivo estático de la aplicación.
- La configuración es simple y se evitan las desventajas de usar un CDN.
- Se usan
htmx.org@2.0.10 y el framework CSS sin clases bamboo.css@1.4.0.
wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css
Separación de plantillas base, de página y parciales
base.tmpl define el esqueleto completo del documento, como <head>, el título común y <main>, e inserta page:title y page:content.
- Bamboo CSS y el script de HTMX se cargan desde la plantilla base, y al script de HTMX se le asigna el atributo
defer.
- El navegador descarga el script en paralelo mientras analiza el HTML.
- El script se ejecuta después de que finalizan el análisis del HTML y la construcción del DOM.
- Todas las plantillas reciben un nombre explícito con
{{define}}...{{end}}, sin depender del nombre del archivo.
- El código Go puede referenciar las plantillas de forma consistente solo por el nombre definido.
- El
: de page:title es un separador arbitrario; también podrían usarse nombres como page_title, page-title, pageTitle o title.
- En el botón de la página de inicio se definen dos comportamientos:
hx-get="/gopher": al hacer clic, envía una solicitud GET /gopher.
hx-swap="outerHTML": reemplaza el propio botón con el HTML de respuesta.
partial:image:gopher es un fragmento de imagen reutilizable, y mediante width="{{.}}" recibe el ancho de la imagen en el momento de la llamada.
- Los fragmentos HTML usados solo en una página específica pueden colocarse en el archivo de esa página, no en el directorio común
partials.
- La distinción es dejar en el directorio común los fragmentos usados por varias páginas y ubicar los fragmentos exclusivos de una página junto al contenido relacionado.
Incluir HTML y recursos estáticos en el binario de Go
- Desde que en Go 1.16 se agregó el embedding de archivos, HTML y recursos estáticos pueden incluirse en el binario de Go en lugar de leerse desde disco en tiempo de ejecución.
- Con la directiva
//go:embed "html" "static", ambos directorios se colocan en embed.FS, y con fs.Sub() se separan en dos subsistemas de archivos:
HTMLFiles: sistema de archivos de plantillas que usa html como raíz.
StaticFiles: sistema de archivos estáticos que usa static como raíz.
- Separarlos aporta dos beneficios:
- El código de HTML y el de archivos estáticos no acceden innecesariamente a los archivos del otro.
- Al abrir archivos no hace falta agregar los prefijos
html/ o static/.
- Si no se quiere usar
panic() en sub(), también se puede devolver el error e inicializar las dos variables en main().
- En la implementación actual, para que
fs.Sub() falle, el valor del directorio tendría que no ser una ruta válida; pero las cadenas fijas "html" y "static" pasan esa validación, por lo que el riesgo de pánico en tiempo de ejecución es muy bajo.
Conjunto compartido de plantillas y htmlRenderer
htmlRenderer administra el flujo de renderizado de la siguiente manera:
- Al iniciar, analiza una vez el conjunto compartido de plantillas.
- En cada renderizado, clona el conjunto compartido.
- Analiza adicionalmente las plantillas de página necesarias para la solicitud.
- Ejecuta la plantilla con el nombre indicado y la envía como respuesta HTTP.
newHTMLRenderer() registra template.FuncMap y luego analiza las plantillas comunes.
- Conecta
now con time.Now.
- Otras funciones de plantilla personalizadas también pueden agregarse en el mismo lugar.
render() crea un conjunto de plantillas por solicitud con sharedTemplates.Clone() y, si hay additionalTemplateFiles, lo extiende con ParseFS().
- Primero se ejecuta la plantilla en un
bytes.Buffer, luego se registra el código de estado y se envía el cuerpo de la respuesta.
- Al inicializar el servidor,
"base.tmpl" y "partials/*.tmpl" se analizan como conjunto compartido.
- La plantilla base y todos los fragmentos comunes siempre están disponibles en el renderizador.
- Solo las plantillas específicas de cada página se agregan al llamar al handler.
- Los archivos estáticos se sirven con
http.FileServerFS(assets.StaticFiles) y la ruta /static/, y el servidor corre en :5051.
Renderizado de páginas completas y fragmentos HTML
- El handler de la página de inicio devuelve el documento completo con la siguiente llamada:
app.html.render(w, 200, nil, "base", "pages/home.tmpl")
- Es una llamada que agrega
pages/home.tmpl al conjunto compartido y ejecuta la plantilla base con 200 OK.
- El handler
/gopher no analiza archivos adicionales y solo ejecuta partial:image:gopher, que ya está incluido en el conjunto compartido.
width := 100
app.html.render(w, http.StatusOK, width, "partial:image:gopher")
- HTMX reemplaza el botón por la imagen recibida desde
/gopher según la configuración hx-swap="outerHTML"
- Esta estructura ofrece las siguientes ventajas
- Las plantillas y los recursos estáticos quedan incluidos en el binario, lo que facilita el despliegue
- Con el mismo
render() se puede devolver tanto una página HTML completa como un fragmento HTML específico
- Se reduce la duplicación de marcado mediante una plantilla base y plantillas parciales
- Las plantillas parciales se pueden reutilizar en la plantilla base, en el contenido de páginas y también en otras plantillas parciales
Búsqueda de usuarios que se actualiza al escribir
- La función de búsqueda se divide en dos rutas
GET /users: página HTML completa con la información de todos los usuarios
GET /users/search: fragmento HTML que devuelve solo las filas de la tabla de los usuarios cuyo nombre o email coincide con el término de búsqueda
- El campo de búsqueda combina los siguientes atributos de HTMX
- Envía la solicitud de búsqueda con
hx-get="/users/search"
- Solicita después de que pasen 500 ms tras un cambio en la entrada, o al presionar Enter, con
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
- El término de búsqueda se pasa como cadena de consulta con la forma
GET /users/search?query=foo
- Inserta la respuesta dentro de
<tbody id="search-results"> con hx-target="#search-results"
- Actualiza la barra de direcciones y agrega una entrada al historial del navegador en cada solicitud con
hx-push-url="true"
users:rows es una plantilla separada que renderiza solo las filas de usuarios
- En cada fila se muestran el nombre y el email
- Si
IsGopher es verdadero, renderiza partial:image:gopher con ancho 24
listUsers y searchUsers agregan pages/users.tmpl al conjunto compartido, pero ejecutan plantillas diferentes
listUsers: ejecuta base y devuelve la página completa
searchUsers: ejecuta users:rows y devuelve solo las filas coincidentes
- Si el término de búsqueda está vacío, se usa la lista completa de usuarios; si tiene un valor, se revisan el nombre y el email con
strings.Contains()
- En una aplicación real, se pueden considerar las siguientes mejoras
- Unificar los handlers de listado y búsqueda
- Centralizar los helpers de manejo de errores y logging
- Agregar encabezados de Content Security Policy y recuperación ante pánicos mediante middleware
- Configurar adecuadamente los timeouts del servidor
Distinguir entre página completa y respuesta parcial con HX-Request
- Si el usuario abre
/users/search?query=leo directamente en el navegador o llega mediante un enlace compartido, es posible que solo vea el HTML parcial correspondiente a las filas de la tabla
- Las solicitudes de HTMX siempre incluyen el encabezado
HX-Request: true, por lo que se decide el tipo de respuesta con base en él
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
- Se deja
base como plantilla de ejecución predeterminada del handler de búsqueda y se cambia a users:rows solo cuando es una solicitud HTMX
- Si se visita directamente, se recibe una página completa con los resultados de búsqueda
- En una solicitud HTMX, solo se devuelve el fragmento de filas para insertar en el
<tbody>
Encabezado Vary y restauración al volver atrás
- Como una misma URL devuelve respuestas distintas según el valor de
HX-Request, también hay que informar esa diferencia a la caché
- Se agrega
Vary: HX-Request a todas las respuestas renderizadas
- En rigor, configurarlo solo en los handlers donde haga falta genera menos desperdicio
- Configurarlo de forma global en el renderizador evita bugs que podrían surgir por omisiones
- Cuando
hx-push-url o hx-boost agregan entradas al historial del navegador, HTMX guarda en caché el HTML de la página completa en el almacenamiento local del navegador
- La caché predeterminada es de hasta 10 páginas
- Si se retrocede hasta un punto que no está en caché, HTMX vuelve a solicitar esa URL al servidor
- La solicitud de restauración predeterminada incluye
HX-Request: true, por lo que el servidor podría devolver HTML parcial en lugar de una página completa
- Si se elige el tipo de respuesta mediante
HX-Request, se debe configurar historyRestoreAsHxRequest: false
- Excluye
HX-Request: true en solicitudes de restauración por fallo de caché
- Hace que el servidor devuelva una página HTML completa para esa solicitud
- La configuración de HTMX se coloca en la metaetiqueta
htmx-config de base.tmpl
Redirigir una solicitud HTMX a otra página
- HTMX puede reemplazar de inmediato fragmentos HTML como mensajes de éxito, lo que reduce los casos en los que se necesita el flujo habitual Post/Redirect/Get
- Cuando se debe enviar al usuario a una página completamente distinta, como ir al perfil después de iniciar sesión, no se puede resolver solo con una respuesta 3xx normal
- El navegador sigue la respuesta 3xx antes que HTMX
- HTMX solo ve la respuesta final después de la redirección y reemplaza con ese contenido el destino existente
- En solicitudes HTMX se usa una respuesta 2xx junto con el encabezado
HX-Redirect
HX-Redirect: /foo/bar mueve el navegador a esa URL y vuelve a cargar la página completa
- La solicitud al destino no incluye
HX-Request: true
- Para soportar también entornos con JavaScript desactivado o donde HTMX no se haya cargado, las solicitudes que no sean HTMX deben mantener una respuesta 3xx normal
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if isHTMXRequest(r) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, url, code)
}
- Al ir a
/profile después del inicio de sesión, se puede llamar así
redirect(w, r, "/profile", http.StatusSeeOther)
Diferencia entre HX-Redirect y HX-Location
HX-Location realiza un comportamiento similar a una redirección sin recargar la página completa
- Obtiene el HTML de la URL de destino
- Reemplaza la respuesta en
<body>
- Agrega una nueva entrada al historial del navegador
- Al solicitar la URL de destino, se incluye
HX-Request: true
- Si la ruta de destino devuelve HTML parcial según
HX-Request, pueden surgir problemas
- El handler no puede distinguir entre una solicitud que viene de
HX-Location y una solicitud HTMX normal
- La primera necesita una página completa, pero la segunda necesita una respuesta parcial
- A diferencia de la restauración del historial, no existe una configuración para desactivar
HX-Request: true en una navegación con HX-Location
- En la mayoría de los casos, es más fácil y seguro aceptar la recarga de página completa y usar
HX-Redirect
- Si la ruta de destino está configurada para devolver siempre HTML completo, también se puede usar
HX-Location
Manejo de respuestas 204, 422, 4xx y 5xx
- De forma predeterminada, HTMX no reemplaza en el DOM las respuestas 4xx o 5xx
- Mantiene la pantalla actual tal como está
- El error se registra en la consola de herramientas para desarrolladores
- Aunque el servidor devuelva
500 Internal Server Error por el nombre de una plantilla inexistente, con la configuración predeterminada el usuario no puede ver el error en pantalla
- Para mostrar mensajes de error al usuario, las respuestas 4xx y 5xx se configuran para reemplazar todo el
<body>
- La excepción es
422 Unprocessable Content
- Se usa al devolver un formulario que contiene errores de validación
- La respuesta debe reemplazarse normalmente en el destino HTMX existente
responseHandling se configura así según el código de estado
204 No Content: no modifica el DOM
422 Unprocessable Content: reemplazo normal en el destino existente
- Otros 4xx y 5xx: reemplaza la respuesta en
<body>
- Las demás respuestas: reemplazo normal en el destino existente
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
]
- Si en una interacción específica necesitas colocar los errores en un destino distinto de
<body>, puedes sobrescribir la configuración predeterminada con la extensión response target.
Comprobar la URL actual en la barra de direcciones del navegador
- HTMX no cambia la URL del navegador durante las solicitudes AJAX ni el reemplazo de respuestas si no se usan
hx-boost, hx-push-url o hx-replace-url.
- Por eso,
r.URL del handler de Go puede ser distinto de la URL que el usuario ve en la barra de direcciones.
- HTMX envía la URL que el navegador muestra actualmente en el encabezado
HX-Current-URL de cada solicitud.
- La función auxiliar parsea este encabezado como
url.URL y, si no existe, devuelve r.URL.
func browserURL(r *http.Request) (*url.URL, error) {
cu := r.Header.Get("HX-Current-URL")
if cu != "" {
return url.Parse(cu)
}
return r.URL, nil
}
Configuración para cambiar los valores predeterminados de HTMX
- Con
historyCacheSize: 0, se desactiva por completo la caché de páginas en el almacenamiento local de HTMX.
- La caché en almacenamiento local puede ser una fuente de bugs y problemas de seguridad.
- Al presionar Atrás, el HTML se vuelve a obtener del servidor en lugar de usar la caché.
- En futuras versiones de HTMX, el cacheo en almacenamiento local vendrá desactivado por defecto por el mismo motivo.
- Con
disableInheritance: true, se desactiva la herencia de atributos de HTMX.
- Es más claro declarar siempre los atributos de forma explícita.
- Reduce el riesgo de bugs o comportamientos no deseados.
- En futuras versiones de HTMX, la herencia de atributos también vendrá desactivada por defecto.
- Con
includeIndicatorStyles: false, se evita que HTMX inyecte estilos para los indicadores.
- Los estilos de los indicadores se definen directamente junto con otras reglas CSS para mantener la consistencia.
- Las solicitudes HTMX no tienen tiempo límite de forma predeterminada y esperan hasta que el servidor responda.
- Según cómo la aplicación Go maneje los tiempos límite y los deadlines, se puede especificar
timeout en milisegundos.
- Como punto de partida, se usa
timeout: 5000.
- La configuración base también incluye
historyRestoreAsHxRequest: false y responseHandling por código de estado.
<meta
name="htmx-config"
content='{
"includeIndicatorStyles": false,
"historyCacheSize": 0,
"historyRestoreAsHxRequest": false,
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
],
"timeout": 5000
}'
>
Layouts por página para aplicaciones grandes
- Si no alcanza con una plantilla base y plantillas por página, se puede agregar una plantilla de layout entre ambas capas.
- Es adecuada para áreas que necesitan un título, un menú de navegación y una estructura de cuerpo distintos a los de las páginas comunes, como una página de administración.
base llama a layout en lugar de llamar directamente a page:content.
<body>
{{template "layout" .}}
</body>
layouts/admin.tmpl define la estructura común del área de administración y llama internamente a page:content.
- Título del área de administración
- Menú de navegación hacia usuarios y pedidos
- Un
<main> donde va el contenido específico de cada página
- El handler pasa a
render() tanto el layout como la plantilla de página que se usarán.
app.html.render(
w,
200,
nil,
"base",
"layouts/admin.tmpl",
"pages/admin-orders.tmpl",
)
- Al mantener una plantilla base compartida y combinar layouts por ruta con contenido de página, las pantallas que necesitan una estructura separada, como el área de administración, también pueden ampliarse con el mismo patrón de renderizado.
1 comentarios
Opiniones en Hacker News
Me gusta Go + HTMX, y lo uso junto con a-h/templ para aumentar la seguridad de tipos en plantillas, componentes y HTML parcial. Publiqué este conjunto completo de herramientas, compuesto por Go, Unix y SQLite, llamándolo stack GUS: https://housecat.com/blog/the-gus-stack-go-unix-sqlite Está muy inspirado en el stack GUTS de exe.dev, pero usa HTMX en lugar de TypeScript: https://exe.dev/docs/guts Uso cockroachdb/errors para errores con stack trace, templ para HTML con seguridad de tipos, fuego para generar especificaciones OpenAPI, sqlc para generar código SQL, modernc.org/sqlite para SQLite puro en Go, goose para migraciones, dbos para flujos de trabajo de persistencia, y rod para pruebas y automatización con Chrome/CDP. Esta combinación resulta productiva tanto para programar directamente o con ayuda de agentes, como para compilar y desplegar un único binario.
go generate ./...como paso de compilación, y con goverter también genero conversiones entre modelos de sqlc y objetos/valores de retorno de plantillas. Alrededor del 50% del código repetitivo se genera automáticamente, lo que mejora mucho la seguridad de tipos.HTMX es excelente para muchos usos, pero es muy difícil adoptarlo si tus compañeros de equipo no están de acuerdo. La resistencia de que no es una tecnología seria continúa, y todo tipo de bugs se atribuyen primero a HTMX; aunque después se demuestre que era un malentendido, la confianza ya queda dañada. Incluso me pasó en el mejor equipo en el que he trabajado hasta ahora, y al final aprendí que hay que elegir qué batallas pelear. También considero que html/template de Go tiene una interfaz especialmente poco natural. Como recomienda 《A Philosophy of Software Design》, la complejidad debería empujarse hacia el interior, pero una estructura en la que hay que preocuparse por clonar plantillas cada vez que se renderiza HTML es innecesariamente compleja.
base.tmplenBASE_BEGINyBASE_ENDpara usarlos en la plantilla final. Hay que pasar el título de cada página en tiempo de ejecución, pero el enfoque del artículo original también termina rompiéndose cuando se empieza con el soporte multidioma.En un proyecto reciente disfruté muchísimo usar HTMX. Como alguien que conoce la web anterior a AngularJS y React, me gusta especialmente poder crear páginas reales y minimizar la cantidad de JavaScript. También se puede hacer con JavaScript puro, pero HTMX evita el código repetitivo de manejo de eventos. Si no te gusta la filosofía moderna del frontend, vale la pena probarlo; los primeros ejemplos del sitio oficial son muy básicos, pero al aprender un poco más se vuelve mucho más potente.
Probé crear un proyecto bastante grande con HTMX + Go, pero todavía no fue suficiente, y no estoy seguro de que en el futuro llegue al nivel que quiero. Es excelente para apps CRUD simples y dashboards de administración, pero cuando empiezan a abundar los componentes conectados entre sí, el estado compartido y las interacciones complejas, se vuelve difícil de gestionar muy rápido. Elegí HTMX porque no me gusta React, pero al final mantuve el backend en Go y cambié el frontend a SvelteKit en modo SPA; al separar limpiamente ambos lados, pude desarrollar y mantener una UI compleja con mucha más facilidad. Svelte se siente como una extensión natural de HTML, más que como otro lenguaje llamado JSX; su gestión de estado y modelo de componentes son simples, y la nueva sintaxis
$stateme gusta especialmente.Últimamente escribo casi todos mis proyectos personales en Go + HTMX, con o sin ayuda de LLMs. Opus y GPT también manejan muy bien esta combinación; permite construir y arrancar rápido, y como es un único binario, el despliegue y el hosting también son cómodos. Es un stack muy bueno para iterar rápido.
Si vas a usar HTMX, recomiendo firmemente una forma de generar HTML que permita extraer fragmentos HTML comunes en funciones y componentizarlos fácilmente en el backend, como en React. HTMX necesita flexibilidad en el HTML que genera el backend, por ejemplo para agregar o quitar etiquetas junto a cierto HTML según la situación. Por ejemplo, si encuentra una etiqueta `` en el nivel superior, actualiza el título de la página. En los motores de plantillas tradicionales basados en strings, este tipo de trabajo es difícil, pero con una biblioteca de HTML integrada en el lenguaje es sencillo, y se pueden aprovechar todas las capacidades de abstracción del lenguaje, por lo que encaja especialmente bien con HTMX. Si el backend es JavaScript, se puede usar una función de tagged template literal como https://github.com/WebReflection/uhtml-ssr; en Go, https://www.gomponents.com/; y en Scala, ScalaTags.
renderToStaticMarkup, pero probablemente sea mejor elegir una biblioteca JSX escrita específicamente para backend.Conocí Go por primera vez con el libro de Alex Edwards y Learn Go with Tests, y todavía los recomiendo. Me dieron ganas de intentar convertir a HTMX algunos proyectos antiguos.
Creé para uso personal un framework basado en Kotlin + HTMX: https://github.com/reubenfirmin/zoned El objetivo era comprobar si podía hacer una app web completa con seguridad de tipos de extremo a extremo, usando Kotlinx.html para escribir HTML con un DSL similar a JSX. Hice por mi cuenta el primer 90%, y para el último 10% y el README recibí ayuda de Claude para terminar de publicarlo.
Me gustan tanto Go como Alex Edwards, pero los resultados al usar HTMX siempre me decepcionaron. Siento que la complejidad del código base crece el doble de rápido que la propia app, y siempre aparecen casos límite de los que no se puede salir sin trucos raros. Entiendo por qué a la gente no le gustan los paquetes de Node, pero HTMX me parece una sobrecompensación frente a eso. El tiempo que se ahorra al no pelearse con JSON se multiplica por tres en lograr que la app se vea y se sienta bien. Con un template de Mantine puedes configurarlo en 2 minutos y aprovechar excelentes componentes de UI; si incluyes los assets estáticos generados, puedes obtener igualmente un único binario de Go: https://github.com/mantinedev/vite-min-template
Prefiero mucho más Go + Datastar porque es más simple.