← Voltar para o blog

templ em Go: Componentes HTML Type-Safe com net/http e HTMX

Aprenda templ em Go: instalação, componentes type-safe, layouts, net/http, Chi, formulários, HTMX, testes, segurança e comparação com html/template.

Se você quer construir uma interface web em Go sem levar React, Vue ou outro framework JavaScript para o projeto, templ é uma das opções mais práticas do ecossistema atual. Ele transforma arquivos .templ em código Go, permite criar componentes HTML com parâmetros tipados e renderiza tudo no servidor. O resultado é um fluxo simples: o handler busca os dados, chama um componente e devolve HTML.

A recomendação direta é esta: use html/template quando a aplicação tem poucas páginas e templates tradicionais; avalie templ quando a interface começou a exigir componentes reutilizáveis, layouts, estados de loading/erro e integração frequente com HTMX. Você continua no ecossistema Go, ganha verificação no build e não precisa criar uma API JSON só para o frontend consumir.

Este guia mostra instalação, componentes, layouts, integração com net/http e Chi, formulários, HTMX, testes e segurança. Se a sua base HTTP ainda não está pronta, comece pelo tutorial de API REST em Go e pelo guia de middleware com net/http, Chi e Gin.

O que é templ — e o que ele resolve

O pacote padrão html/template interpreta arquivos de template em tempo de execução. Ele é seguro, maduro e suficiente para muitos produtos. O templ segue outra estratégia: você escreve uma sintaxe próxima de HTML em arquivos .templ, executa um gerador e recebe arquivos .go que entram na compilação normal.

Isso muda a experiência de desenvolvimento em três pontos:

  1. Parâmetros têm tipos Go. Um componente pode receber User, []Job ou error, e o compilador verifica as chamadas.
  2. Componentes são funções. Cabeçalho, card, tabela, alerta e layout podem ser combinados sem depender de herança de templates.
  3. Boa parte dos erros aparece no build. Uma chamada incompatível ou expressão Go inválida falha antes do deploy.

O templ não é um framework web completo. Ele não escolhe router, banco, autenticação, cache ou estratégia de deploy. Ele resolve a camada de renderização HTML no servidor. Essa separação combina com a filosofia do Clean Architecture em Go sem overengineering: handlers pequenos, domínio independente e componentes focados em apresentação.

Instalação e primeiro componente

Instale a CLI e adicione o módulo ao projeto:

go install github.com/a-h/templ/cmd/templ@latest
go get github.com/a-h/templ

Crie views/hello.templ:

package views

templ Hello(name string) {
    <!doctype html>
    <html lang="pt-BR">
        <head>
            <meta charset="utf-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1"/>
            <title>Olá com templ</title>
        </head>
        <body>
            <main>
                <h1>Olá, { name }!</h1>
                <p>Esta página foi renderizada pelo servidor Go.</p>
            </main>
        </body>
    </html>
}

Gere o código:

templ generate

O comando cria um arquivo Go ao lado do .templ. Esse arquivo gerado deve ser tratado como artefato: não edite na mão. O código-fonte é o arquivo .templ; sempre que ele mudar, rode templ generate novamente. No CI, uma boa prática é gerar e depois verificar se o repositório ficou limpo, evitando deploy com código gerado desatualizado.

Renderizando com net/http

Todo componente implementa uma interface com método Render(context.Context, io.Writer) error. Por isso, a integração com net/http é pequena:

package main

import (
    "log"
    "net/http"

    "example.com/site/views"
)

func home(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/html; charset=utf-8")

    if err := views.Hello("Gopher").Render(r.Context(), w); err != nil {
        http.Error(w, "erro ao renderizar página", http.StatusInternalServerError)
    }
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /", home)

    log.Fatal(http.ListenAndServe(":8080", mux))
}

Em uma aplicação de produção, evite chamar http.Error depois que parte do HTML já foi escrita, porque o status 200 pode ter sido enviado. Uma alternativa é renderizar primeiro em um buffer e só copiar para o ResponseWriter se não houver erro:

func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
    var buf bytes.Buffer
    if err := c.Render(r.Context(), &buf); err != nil {
        slog.ErrorContext(r.Context(), "falha ao renderizar", "err", err)
        http.Error(w, "erro interno", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    _, _ = buf.WriteTo(w)
}

Esse helper se encaixa naturalmente em uma chain com recovery, request ID e logging estruturado. Para erros de produção, use também o padrão do slog em Go, sem expor detalhes internos para o navegador.

Componentes, propriedades e composição

O ganho real aparece quando a página deixa de ser um bloco único. Considere um portal de vagas com um tipo de domínio:

package model

type Job struct {
    Title    string
    Company  string
    Location string
    Remote   bool
}

O componente recebe esse tipo diretamente:

package views

import "example.com/site/model"

templ JobCard(job model.Job) {
    <article class="job-card">
        <h2>{ job.Title }</h2>
        <p>{ job.Company }  { job.Location }</p>
        if job.Remote {
            <span class="pill remote">Remoto</span>
        }
    </article>
}

templ JobList(jobs []model.Job) {
    <section aria-label="Vagas encontradas">
        if len(jobs) == 0 {
            <p>Nenhuma vaga encontrada com estes filtros.</p>
        } else {
            for _, job := range jobs {
                @JobCard(job)
            }
        }
    </section>
}

A chamada @JobCard(job) compõe um componente dentro de outro. Se JobCard mudar para receber outro tipo, os locais incompatíveis falham na compilação. Compare isso com um template baseado em map[string]any: um campo renomeado pode virar um valor vazio percebido apenas depois que a página estiver no ar.

Não passe o banco ou serviços para os componentes. Busque e prepare os dados no handler ou na camada de aplicação, depois entregue uma view model pequena. Isso facilita testes e impede que a camada de HTML controle transações, timeouts ou regras de negócio.

Layout compartilhado sem duplicação

Um layout pode receber o título e um bloco filho:

package views

templ Layout(title string) {
    <!doctype html>
    <html lang="pt-BR">
        <head>
            <meta charset="utf-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1"/>
            <title>{ title }</title>
        </head>
        <body>
            <header>
                <a href="/">Minha aplicação</a>
            </header>
            <main>
                { children... }
            </main>
        </body>
    </html>
}

templ Home(jobs []model.Job) {
    @Layout("Vagas de Go") {
        <h1>Vagas de Go</h1>
        @JobList(jobs)
    }
}

Esse padrão resolve o equivalente a base.html, block e define de outros sistemas, mas com composição explícita. Também permite criar layouts separados para área pública, painel autenticado e e-mails, sem um arquivo global cheio de condicionais.

templ com HTMX: HTML parcial em vez de API JSON

HTMX adiciona atributos ao HTML para fazer requisições e substituir fragmentos da página. Ele combina bem com templ porque o servidor já sabe renderizar componentes. Em vez de manter um componente React e uma resposta JSON com formatos sincronizados, você devolve o próprio HTML final.

Um formulário de busca pode ficar assim:

templ SearchForm() {
    <form
        hx-get="/vagas/buscar"
        hx-target="#resultados"
        hx-trigger="submit, input changed delay:400ms from:#q"
        hx-push-url="true">
        <label for="q">Buscar vaga</label>
        <input id="q" name="q" type="search" autocomplete="off"/>
        <button type="submit">Buscar</button>
    </form>
    <div id="resultados"></div>
}

A rota devolve apenas a lista:

func searchJobs(repo JobRepository) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        query := strings.TrimSpace(r.URL.Query().Get("q"))

        jobs, err := repo.Search(r.Context(), query)
        if err != nil {
            http.Error(w, "não foi possível buscar", http.StatusInternalServerError)
            return
        }

        render(w, r, views.JobList(jobs))
    }
}

Use HTMX quando a interação é naturalmente servidor-cêntrica: filtros, paginação, formulários, modais simples, atualização de status e tabelas. Para editor visual complexo, canvas, colaboração em tempo real ou estado offline sofisticado, um frontend dedicado ainda pode ser a melhor escolha.

HTMX não remove preocupações HTTP. Você continua precisando de CSRF, autenticação e autorização, timeout, validação e respostas corretas. O benefício é reduzir a duplicação entre a representação JSON e a interface que a consome.

Formulários, validação e mensagens de erro

A validação deve acontecer no servidor, mesmo que exista validação no navegador. Uma abordagem limpa é criar uma view model que guarda valores enviados e erros por campo:

type SignupForm struct {
    Name   string
    Email  string
    Errors map[string]string
}

O componente usa os dados sem conhecer banco ou serviço:

templ Signup(form SignupForm) {
    <form method="post" action="/cadastro">
        <label for="name">Nome</label>
        <input id="name" name="name" value={ form.Name }/>
        if message := form.Errors["name"]; message != "" {
            <p role="alert">{ message }</p>
        }

        <label for="email">E-mail</label>
        <input id="email" name="email" type="email" value={ form.Email }/>
        if message := form.Errors["email"]; message != "" {
            <p role="alert">{ message }</p>
        }

        <button type="submit">Criar conta</button>
    </form>
}

Ao receber POST inválido, responda 422 Unprocessable Entity e renderize o formulário preenchido com mensagens claras. Com HTMX, o mesmo fragmento pode substituir o formulário atual. Sem HTMX, você pode renderizar a página inteira ou aplicar Post/Redirect/Get depois de uma submissão bem-sucedida.

Segurança: escaping ajuda, mas não resolve tudo

Conteúdo dinâmico inserido normalmente por templ é escapado, o que protege contra boa parte dos ataques de cross-site scripting (XSS). Se o nome vier como <script>alert(1)</script>, ele deve aparecer como texto, não ser executado.

Ainda assim, revise estas fronteiras:

  • HTML confiável: não transforme texto de usuário em HTML bruto. Sanitização exige uma política explícita.
  • URLs dinâmicas: valide esquemas e destinos; não aceite javascript: nem redirects arbitrários.
  • Atributos e JavaScript: evite montar trechos executáveis a partir de entrada externa.
  • CSRF: formulários autenticados que alteram estado precisam de proteção, independentemente do renderer.
  • Headers: configure Content Security Policy, X-Content-Type-Options e outras proteções adequadas ao produto.
  • Autorização: esconder um botão no componente não impede acesso à rota. A permissão real é verificada no servidor.

A regra é a mesma do guia de segurança em aplicações Go: escaping é uma camada, não uma licença para confiar na entrada.

Testando componentes templ

Como o componente escreve em um io.Writer, você pode testá-lo sem iniciar servidor:

func TestJobCard(t *testing.T) {
    job := model.Job{
        Title:    "Pessoa Desenvolvedora Go",
        Company:  "Empresa Exemplo",
        Location: "Brasil",
        Remote:   true,
    }

    var buf bytes.Buffer
    err := views.JobCard(job).Render(context.Background(), &buf)
    if err != nil {
        t.Fatal(err)
    }

    html := buf.String()
    if !strings.Contains(html, "Pessoa Desenvolvedora Go") {
        t.Fatalf("título ausente: %s", html)
    }
    if !strings.Contains(html, "Remoto") {
        t.Fatalf("selo remoto ausente: %s", html)
    }
}

Para componentes pequenos, assertions semânticas são mais estáveis que snapshots gigantes. Verifique texto importante, links, atributos de acessibilidade e estados condicionais. Para fluxos completos, combine httptest com testes do handler. O guia de table-driven tests ajuda a cobrir listas vazias, erros e diferentes permissões sem repetir setup.

Inclua estas etapas no CI:

templ generate
templ fmt .
go test ./...
go vet ./...

Se o time versiona arquivos gerados, rode também git diff --exit-code após templ generate. Assim, um pull request falha quando alguém altera .templ e esquece de atualizar o .go correspondente.

templ ou html/template: como escolher

Critériotemplhtml/template
Dependência externaSimNão, biblioteca padrão
ModeloComponentes geradosTemplates interpretados
Tipagem dos parâmetrosForte, via GoDados verificados principalmente em runtime
Reutilização de UIComposição de componentesdefine, template e funções auxiliares
BuildExige geraçãoNão exige codegen
Curva inicialSintaxe e ferramenta novasFamiliar e amplamente documentado
Melhor encaixeUI com componentes e HTMXPáginas tradicionais e projetos simples

Escolha html/template se você tem uma landing page, e-mails simples ou um painel pequeno e quer zero dependências. Escolha templ se a interface possui vários componentes, a equipe valoriza erros no build e o produto usa renderização server-side com atualizações parciais.

Não migre apenas porque a ferramenta está em alta. Se os templates atuais são estáveis, testados e fáceis de manter, o retorno pode ser pequeno. Se a equipe sofre com map[string]any, nomes de blocos, duplicação de componentes e erros descobertos no navegador, templ provavelmente paga o custo de adoção.

Estrutura de projeto recomendada

Uma estrutura simples evita misturar apresentação e regra de negócio:

cmd/web/main.go
internal/domain/job.go
internal/application/search_jobs.go
internal/http/handlers/jobs.go
internal/http/middleware/
views/layout.templ
views/jobs.templ
views/components/
static/css/
static/js/

internal/http/handlers converte request em chamada de aplicação. internal/application coordena casos de uso. views recebe dados prontos e renderiza. Arquivos estáticos ficam separados e podem ser incorporados no binário com go:embed. Para encerrar o servidor sem cortar respostas durante deploy, aplique também graceful shutdown em Go.

Se o objetivo é portfólio e carreira, uma aplicação server-rendered completa mostra mais maturidade que uma página isolada: autenticação, formulário validado, PostgreSQL, testes, Docker e README com trade-offs. Veja as ideias de projetos Go para portfólio e as vagas de Go no Brasil para conectar a prática ao que empresas estão pedindo.

Checklist antes de colocar em produção

  • templ generate roda no desenvolvimento e no CI.
  • Mudanças em arquivos gerados são verificadas antes do merge.
  • Componentes recebem view models, não conexões de banco ou serviços.
  • Erros de renderização são logados sem vazar detalhes para o usuário.
  • Conteúdo dinâmico permanece escapado; HTML bruto é exceção revisada.
  • Formulários têm validação no servidor e proteção CSRF quando necessária.
  • Handlers propagam r.Context() para banco e serviços.
  • HTMX recebe códigos HTTP e fragmentos coerentes para sucesso e erro.
  • Componentes críticos têm testes de estados vazios, erro e permissão.
  • A aplicação tem logging, recovery, timeout e shutdown gracioso.

Perguntas frequentes

O que é templ em Go?

templ é uma linguagem e ferramenta de geração de código para criar componentes HTML type-safe em Go. Arquivos .templ são transformados em código Go, compilados junto com a aplicação e renderizados no servidor. Isso permite detectar vários erros no build, reutilizar componentes e trabalhar com tipos Go sem montar HTML por concatenação de strings.

templ substitui html/template?

Não obrigatoriamente. html/template continua sendo excelente, faz parte da biblioteca padrão e atende páginas tradicionais com poucas abstrações. templ é especialmente útil quando a interface cresce em componentes, layouts e estados reutilizáveis. A decisão depende da complexidade da UI, da política de dependências e da preferência do time.

Preciso de React para usar templ?

Não. templ renderiza HTML no servidor e funciona apenas com net/http se a página for simples. Para interações sem recarregar a página inteira, HTMX combina bem com templ porque solicita fragmentos HTML ao servidor. JavaScript pequeno e específico ainda pode ser adicionado quando necessário.

templ protege contra XSS?

templ escapa conteúdo dinâmico por padrão quando ele é inserido como texto ou atributo. Isso reduz o risco de XSS, mas não elimina a necessidade de validar URLs, restringir conteúdo HTML confiável, configurar headers de segurança e evitar transformar entrada do usuário em HTML não escapado.

templ funciona com Gin, Chi e net/http?

Sim. Um componente templ expõe Render(context.Context, io.Writer), portanto pode ser renderizado em qualquer handler. Com net/http e Chi, a integração é direta. Em Gin, Echo ou outros frameworks, basta usar o contexto da requisição e o ResponseWriter oferecido pelo framework.

Conclusão

templ ocupa um espaço útil no ecossistema Go: interfaces server-rendered com componentes, tipagem e pouca infraestrutura de frontend. Ele não torna html/template obsoleto e não substitui um framework JavaScript em todas as situações. Seu valor aparece quando você quer manter a aplicação centrada no servidor, reutilizar UI como funções Go e combinar HTML parcial com HTMX.

Comece pequeno: uma página, um layout e dois componentes. Coloque geração e testes no CI, mantenha regras de negócio fora da view e trate segurança como responsabilidade do sistema inteiro. Se a experiência melhorar sem criar uma cadeia de ferramentas maior do que o produto precisa, templ é uma escolha pragmática — bem alinhada com a cultura de Go.