Criar interfaces web dinâmicas em Go tradicionalmente significa escolher entre duas opções: servir HTML estático com templates ou criar uma API REST que alimenta um frontend React/Vue/Angular. A primeira opção é simples mas limitada; a segunda adiciona complexidade enorme com dois projetos separados, build tools, state management e centenas de dependências npm.
HTMX oferece um caminho do meio: você escreve seu backend em Go com templates HTML normais, e o HTMX adiciona interatividade diretamente no HTML — sem escrever JavaScript. Partial page updates, formulários reativos, infinite scroll, tudo via atributos HTML.
O que É HTMX?
HTMX é uma biblioteca JavaScript minúscula (~14 KB gzipped) que estende o HTML com atributos para fazer requisições HTTP e atualizar partes da página. Em vez de o browser só fazer GET e POST via links e formulários, HTMX permite que qualquer elemento faça qualquer requisição HTTP e atualize qualquer parte da página:
<!-- Ao clicar, faz GET /api/contatos e substitui o conteúdo da div -->
<button hx-get="/api/contatos" hx-target="#lista-contatos" hx-swap="innerHTML">
Carregar Contatos
</button>
<div id="lista-contatos">
<!-- Conteúdo será inserido aqui -->
</div>
Sem JavaScript. Sem fetch(). Sem useState. O servidor retorna fragmentos HTML, não JSON — e o HTMX injeta direto na página.
Por que Go + HTMX É uma Combinação Poderosa
Go já tem tudo que você precisa para web na standard library: net/http para rotas, html/template para renderizar HTML, e performance excepcional para servir requisições. HTMX se encaixa perfeitamente porque:
- Templates Go são perfeitos para fragmentos HTML — você já renderiza HTML no servidor, agora renderiza pedaços de HTML
- Sem serialização JSON — o servidor retorna HTML direto, eliminando marshal/unmarshal
- Um único projeto — backend e frontend no mesmo repositório, mesmo deploy
- Performance — Go serve HTML em microsegundos; HTMX faz swap em milissegundos
- Simplicidade — menos código, menos dependências, menos bugs
Projeto Prático: Lista de Tarefas Dinâmica
Vamos construir uma lista de tarefas completa com Go + HTMX: adicionar, marcar como feita, deletar — tudo sem recarregar a página.
Estrutura do Projeto
meu-projeto/
├── main.go # Servidor e handlers
├── templates/
│ ├── index.html # Página principal
│ └── partials/
│ ├── tarefa.html # Fragmento: uma tarefa
│ └── lista.html # Fragmento: lista de tarefas
└── go.mod
O Servidor Go
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
"sync"
)
// Tarefa representa um item da lista
type Tarefa struct {
ID int
Titulo string
Concluida bool
}
// Store guarda as tarefas em memória (em produção, use um banco)
type Store struct {
mu sync.RWMutex
tarefas []Tarefa
nextID int
}
var store = &Store{nextID: 1}
var tmpl *template.Template
func main() {
// Carregar todos os templates de uma vez
tmpl = template.Must(template.ParseGlob("templates/**/*.html"))
tmpl = template.Must(tmpl.ParseGlob("templates/*.html"))
// Rotas
http.HandleFunc("GET /", handleIndex)
http.HandleFunc("POST /tarefas", handleCriar)
http.HandleFunc("PUT /tarefas/{id}/toggle", handleToggle)
http.HandleFunc("DELETE /tarefas/{id}", handleDeletar)
http.HandleFunc("GET /tarefas/busca", handleBusca)
fmt.Println("Servidor rodando em http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Note que estamos usando o roteador aprimorado do Go 1.22 com métodos HTTP nos patterns (GET /, POST /tarefas). Isso elimina a necessidade de routers externos como gorilla/mux.
Handlers que Retornam HTML
A diferença fundamental em relação a uma API REST é que os handlers retornam fragmentos HTML, não JSON:
// handleIndex renderiza a página completa (só no primeiro acesso)
func handleIndex(w http.ResponseWriter, r *http.Request) {
store.mu.RLock()
defer store.mu.RUnlock()
tmpl.ExecuteTemplate(w, "index.html", map[string]interface{}{
"Tarefas": store.tarefas,
"Total": len(store.tarefas),
})
}
// handleCriar adiciona uma tarefa e retorna apenas o fragmento HTML da nova tarefa
func handleCriar(w http.ResponseWriter, r *http.Request) {
titulo := r.FormValue("titulo")
if titulo == "" {
http.Error(w, "Título é obrigatório", http.StatusBadRequest)
return
}
store.mu.Lock()
tarefa := Tarefa{
ID: store.nextID,
Titulo: titulo,
}
store.nextID++
store.tarefas = append(store.tarefas, tarefa)
store.mu.Unlock()
// Retorna APENAS o HTML da nova tarefa — não a página inteira
tmpl.ExecuteTemplate(w, "tarefa.html", tarefa)
}
// handleToggle marca/desmarca uma tarefa e retorna o fragmento atualizado
func handleToggle(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "ID inválido", http.StatusBadRequest)
return
}
store.mu.Lock()
for i := range store.tarefas {
if store.tarefas[i].ID == id {
store.tarefas[i].Concluida = !store.tarefas[i].Concluida
store.mu.Unlock()
tmpl.ExecuteTemplate(w, "tarefa.html", store.tarefas[i])
return
}
}
store.mu.Unlock()
http.Error(w, "Tarefa não encontrada", http.StatusNotFound)
}
// handleDeletar remove uma tarefa e retorna resposta vazia (HTMX remove o elemento)
func handleDeletar(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
http.Error(w, "ID inválido", http.StatusBadRequest)
return
}
store.mu.Lock()
for i, t := range store.tarefas {
if t.ID == id {
store.tarefas = append(store.tarefas[:i], store.tarefas[i+1:]...)
break
}
}
store.mu.Unlock()
// Retornar vazio — HTMX vai remover o elemento do DOM
w.WriteHeader(http.StatusOK)
}
// handleBusca filtra tarefas e retorna a lista filtrada
func handleBusca(w http.ResponseWriter, r *http.Request) {
termo := r.FormValue("busca")
store.mu.RLock()
var filtradas []Tarefa
for _, t := range store.tarefas {
if termo == "" || contains(t.Titulo, termo) {
filtradas = append(filtradas, t)
}
}
store.mu.RUnlock()
tmpl.ExecuteTemplate(w, "lista.html", filtradas)
}
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr || len(substr) == 0 ||
findSubstring(s, substr))
}
func findSubstring(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
Templates com Atributos HTMX
O template principal carrega o HTMX via CDN e usa atributos hx-* para toda a interatividade:
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="utf-8">
<title>Lista de Tarefas — Go + HTMX</title>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<style>
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; }
.tarefa { display: flex; align-items: center; gap: 8px; padding: 8px; }
.concluida { text-decoration: line-through; opacity: 0.6; }
input[type="text"] { flex: 1; padding: 8px; font-size: 1rem; }
button { padding: 8px 16px; cursor: pointer; }
.htmx-swapping { opacity: 0; transition: opacity 0.3s; }
</style>
</head>
<body>
<h1>Tarefas ({{.Total}})</h1>
<!-- Formulário com HTMX: POST sem recarregar a página -->
<form hx-post="/tarefas"
hx-target="#lista-tarefas"
hx-swap="beforeend"
hx-on::after-request="this.reset()">
<input type="text" name="titulo" placeholder="Nova tarefa..." required>
<button type="submit">Adicionar</button>
</form>
<!-- Busca em tempo real com debounce -->
<input type="text"
name="busca"
placeholder="Buscar tarefas..."
hx-get="/tarefas/busca"
hx-target="#lista-tarefas"
hx-trigger="keyup changed delay:300ms"
hx-swap="innerHTML">
<!-- Lista de tarefas -->
<div id="lista-tarefas">
{{range .Tarefas}}
{{template "tarefa.html" .}}
{{end}}
</div>
</body>
</html>
<!-- templates/partials/tarefa.html -->
{{define "tarefa.html"}}
<div class="tarefa {{if .Concluida}}concluida{{end}}" id="tarefa-{{.ID}}">
<!-- Toggle: PUT request ao clicar no checkbox -->
<input type="checkbox"
{{if .Concluida}}checked{{end}}
hx-put="/tarefas/{{.ID}}/toggle"
hx-target="#tarefa-{{.ID}}"
hx-swap="outerHTML">
<span>{{.Titulo}}</span>
<!-- Deletar: DELETE request com confirmação -->
<button hx-delete="/tarefas/{{.ID}}"
hx-target="#tarefa-{{.ID}}"
hx-swap="outerHTML swap:300ms"
hx-confirm="Remover '{{.Titulo}}'?">
✕
</button>
</div>
{{end}}
<!-- templates/partials/lista.html -->
{{define "lista.html"}}
{{range .}}
{{template "tarefa.html" .}}
{{else}}
<p>Nenhuma tarefa encontrada.</p>
{{end}}
{{end}}
Repare como é elegante: zero JavaScript manual. O formulário faz POST via HTMX, insere a nova tarefa no final da lista (beforeend), e limpa o formulário automaticamente. A busca dispara a cada tecla com debounce de 300ms. O checkbox faz PUT para toggle. O botão de deletar faz DELETE com confirmação.
Atributos HTMX Essenciais
Os atributos mais usados no dia a dia:
| Atributo | Função | Exemplo |
|---|---|---|
hx-get | Faz GET request | hx-get="/api/dados" |
hx-post | Faz POST request | hx-post="/api/criar" |
hx-put | Faz PUT request | hx-put="/api/atualizar/1" |
hx-delete | Faz DELETE request | hx-delete="/api/remover/1" |
hx-target | Onde inserir a resposta | hx-target="#resultado" |
hx-swap | Como inserir (innerHTML, outerHTML, beforeend…) | hx-swap="outerHTML" |
hx-trigger | Quando disparar | hx-trigger="click", "keyup delay:500ms" |
hx-confirm | Confirmação antes de enviar | hx-confirm="Tem certeza?" |
hx-indicator | Mostrar loading | hx-indicator="#spinner" |
Padrões Avançados
Infinite Scroll
<!-- O último item da lista carrega mais quando fica visível -->
<div hx-get="/tarefas?pagina=2"
hx-trigger="revealed"
hx-swap="afterend">
Carregando mais...
</div>
Polling (Atualização Automática)
<!-- Atualiza a cada 5 segundos -->
<div hx-get="/api/status"
hx-trigger="every 5s"
hx-swap="innerHTML">
Status: OK
</div>
Validação em Tempo Real
<input type="email"
name="email"
hx-post="/validar/email"
hx-trigger="blur"
hx-target="#email-feedback">
<span id="email-feedback"></span>
Go + HTMX vs SPA (React/Vue/Angular)
| Aspecto | Go + HTMX | SPA (React/Vue) |
|---|---|---|
| Dependências | 1 (htmx.js, 14KB) | 100+ (node_modules) |
| Complexidade | Baixa | Alta |
| Performance inicial | Excelente (HTML do servidor) | Ruim (bundle JS grande) |
| SEO | Nativo (HTML server-rendered) | Precisa de SSR/SSG |
| Estado | No servidor | Duplicado (cliente + servidor) |
| Curva de aprendizado | 1-2 dias | Semanas/meses |
| Ideal para | CRUD, dashboards, admin, blogs | Apps altamente interativas, offline-first |
HTMX não substitui React para todas as aplicações. Se você precisa de interatividade complexa no cliente (editor de imagens, planilha, IDE online), um SPA faz mais sentido. Mas para 80% das aplicações web — CRUD, dashboards, páginas admin, e-commerce — Go + HTMX é mais rápido de desenvolver e mais performático.
Middleware e Boas Práticas
Para projetos maiores, adicione middleware de logging e tratamento de erros:
// Middleware para detectar requisições HTMX
func isHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// Handler que retorna página completa OU fragmento
func handleProdutos(w http.ResponseWriter, r *http.Request) {
produtos := buscarProdutos()
if isHTMX(r) {
// HTMX pediu: retorna só o fragmento
tmpl.ExecuteTemplate(w, "produtos-lista.html", produtos)
} else {
// Acesso direto: retorna página completa
tmpl.ExecuteTemplate(w, "produtos-pagina.html", produtos)
}
}
Esse padrão é essencial: o mesmo endpoint serve tanto requisições HTMX (fragmentos) quanto acessos diretos (página completa). Assim, URLs funcionam quando compartilhadas e o botão voltar do browser continua funcionando.
Conclusão
Go + HTMX é a stack ideal para desenvolvedores que querem produtividade sem sacrificar performance. Você escreve Go no backend, HTML nos templates, e o HTMX cuida da interatividade. Sem build tools, sem transpilers, sem 500MB de node_modules.
Se quiser ir ainda mais longe com Go na web, explore WebAssembly com Go para rodar lógica Go diretamente no browser, ou aprofunde-se em WebSockets para comunicação bidirecional em tempo real.
Para o backend, nossos tutoriais de Docker, PostgreSQL e Redis mostram como montar a infraestrutura completa. E se você está buscando oportunidades com Go, confira as vagas disponíveis com salários atualizados no mercado brasileiro.