← Voltar para o blog

Go e HTMX: Aplicações Web Modernas Sem JavaScript Pesado

Aprenda a criar aplicações web dinâmicas com Go e HTMX: partial updates, formulários reativos e interfaces modernas sem React ou Vue. Tutorial prático com exemplos.

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:

  1. Templates Go são perfeitos para fragmentos HTML — você já renderiza HTML no servidor, agora renderiza pedaços de HTML
  2. Sem serialização JSON — o servidor retorna HTML direto, eliminando marshal/unmarshal
  3. Um único projeto — backend e frontend no mesmo repositório, mesmo deploy
  4. Performance — Go serve HTML em microsegundos; HTMX faz swap em milissegundos
  5. 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:

AtributoFunçãoExemplo
hx-getFaz GET requesthx-get="/api/dados"
hx-postFaz POST requesthx-post="/api/criar"
hx-putFaz PUT requesthx-put="/api/atualizar/1"
hx-deleteFaz DELETE requesthx-delete="/api/remover/1"
hx-targetOnde inserir a respostahx-target="#resultado"
hx-swapComo inserir (innerHTML, outerHTML, beforeend…)hx-swap="outerHTML"
hx-triggerQuando dispararhx-trigger="click", "keyup delay:500ms"
hx-confirmConfirmação antes de enviarhx-confirm="Tem certeza?"
hx-indicatorMostrar loadinghx-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)

AspectoGo + HTMXSPA (React/Vue)
Dependências1 (htmx.js, 14KB)100+ (node_modules)
ComplexidadeBaixaAlta
Performance inicialExcelente (HTML do servidor)Ruim (bundle JS grande)
SEONativo (HTML server-rendered)Precisa de SSR/SSG
EstadoNo servidorDuplicado (cliente + servidor)
Curva de aprendizado1-2 diasSemanas/meses
Ideal paraCRUD, dashboards, admin, blogsApps 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.