Go Templ+HTMX+Tailwind Full-Stack en Pratique : 5 Motifs Clés pour Construire des Applications Web Modernes Sans Frameworks Frontend
En 2026, la fatigue frontend a atteint son paroxysme. Les Server Components de React 19, l'App Router de Next.js 15, le Suspense de Vue 3.5 — chaque framework ne cesse de se complexifier. La communauté Go offre une réponse radicalement différente : Templ + HTMX + Tailwind. Templ remplace JSX par des templates Go typés, HTMX remplace les interactions JavaScript par des attributs HTML, et Tailwind remplace les bibliothèques de composants par du CSS atomique. Combinés, vous pouvez construire des applications web modernes interactives et performantes sans écrire une seule ligne de JavaScript. Cet article vous guide à travers 5 motifs clés pour maîtriser cette approche de développement full-stack.
Concepts Clés
| Concept | Description | Package/Outil Clé |
|---|---|---|
| Composants Templ | Moteur de templates Go typé avec vérification à la compilation | github.com/a-h/templ |
| Interactions HTMX | Interactions AJAX via attributs HTML, sans JS requis | htmx.org |
| Tailwind CSS | Framework CSS atomique, utilitaire d'abord | tailwindcss |
| Rendu Côté Serveur | Le serveur Go génère le HTML directement, pas de SPA nécessaire | net/http |
| Mises à Jour Partielles | HTMX permet des rafraîchissements partiels sans rechargement complet | hx-get/hx-post |
Analyse du Problème : 5 Points de Douleur du Développement Full-Stack Traditionnel
Point de Douleur 1 : Complexité des Frameworks JavaScript Hors de Contrôle
Une simple application CRUD nécessite une douzaine de fichiers de configuration pour React + TypeScript + Webpack + Babel + ESLint. Le coût d'intégration pour les nouveaux développeurs est extrêmement élevé.
Point de Douleur 2 : Incohérence de Types Frontend-Backend
Les structs Go du backend et les interfaces TypeScript du frontend nécessitent une synchronisation manuelle. Les modifications d'API sont souvent oubliées, provoquant des erreurs d'exécution.
Point de Douleur 3 : Lenteur du Premier Contenu Utile des SPA
Les applications monopages doivent charger un JavaScript massif avant de pouvoir afficher le premier écran. Cela nuit au SEO et dégrade l'expérience utilisateur.
Point de Douleur 4 : Gestion d'État Complexe
Redux, Pinia, Zustand — chaque projet débat sur la bibliothèque de gestion d'état à utiliser. Des fonctionnalités simples sont surdimensionnées.
Point de Douleur 5 : Chaîne de Build Fragile
Les configurations Webpack, les plugins Vite, le traitement PostCSS — n'importe quel maillon défectueux de la chaîne provoque des échecs de build difficiles à déboguer.
Motif Clé 1 : Définition de Composants Templ et Templates Typés
Templ est le moteur de templates typé de Go. Il vérifie la syntaxe et les types des templates à la compilation, éliminant complètement les erreurs de templates à l'exécution. Chaque composant Templ est une fonction Go qui peut accepter des paramètres, appeler d'autres composants et se composer en pages complexes.
// components/layout.templ
package components
// LayoutProps propriétés de mise en page
type LayoutProps struct {
Title string
Description string
}
// Layout composant de mise en page de base
templ Layout(props LayoutProps) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ props.Title }</title>
<meta name="description" content={ props.Description }/>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/response-targets.js"></script>
<link href="/assets/css/app.css" rel="stylesheet"/>
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="text-xl font-bold text-indigo-600">ToolsKu</a>
</div>
<div class="flex items-center space-x-4">
<a href="/tools" class="text-gray-600 hover:text-indigo-600">Tools</a>
<a href="/blog" class="text-gray-600 hover:text-indigo-600">Blog</a>
<a href="/about" class="text-gray-600 hover:text-indigo-600">About</a>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{ children... }
</main>
<footer class="bg-white border-t border-gray-200 mt-12">
<div class="max-w-7xl mx-auto px-4 py-6 text-center text-gray-500 text-sm">
© 2026 ToolsKu. All rights reserved.
</div>
</footer>
</body>
</html>
}
// PageProps propriétés de la page
type PageProps struct {
Title string
Description string
Content templ.Component
}
// Page composant de page avec mise en page
templ Page(props PageProps) {
@Layout(LayoutProps{
Title: props.Title,
Description: props.Description,
}) {
{ props.Content }
}
}
// components/user.templ
package components
import "time"
// User modèle de données utilisateur
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
AvatarURL string `json:"avatar_url"`
}
// UserCard composant carte utilisateur
templ UserCard(user User) {
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
<div class="flex items-center space-x-4">
<img
src={ user.AvatarURL }
alt={ user.Name }
class="w-12 h-12 rounded-full object-cover"
/>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-gray-900 truncate">{ user.Name }</h3>
<p class="text-sm text-gray-500 truncate">{ user.Email }</p>
</div>
<span class={ "px-2.5 py-0.5 rounded-full text-xs font-medium",
templ.KV(user.Role == "admin", "bg-red-100 text-red-800", "bg-green-100 text-green-800") }>
{ user.Role }
</span>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-400">
Joined { user.CreatedAt.Format("2006-01-02") }
</span>
<div class="flex space-x-2">
<button
hx-get={ "/users/" + user.ID + "/edit" }
hx-target="#modal-container"
hx-swap="innerHTML"
class="text-sm text-indigo-600 hover:text-indigo-800"
>
Edit
</button>
<button
hx-delete={ "/users/" + user.ID }
hx-target="closest div"
hx-swap="outerHTML swap:0.3s"
hx-confirm="Are you sure you want to delete this user?"
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button>
</div>
</div>
</div>
}
// UserList composant liste d'utilisateurs
templ UserList(users []User) {
<div id="user-list" class="space-y-4">
for _, user := range users {
@UserCard(user)
}
</div>
}
// UserListPage page de liste d'utilisateurs
templ UserListPage(users []User) {
@Page(PageProps{
Title: "User Management - ToolsKu",
Description: "Manage all users",
Content: userListContent(users),
})
}
templ userListContent(users []User) {
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">User Management</h1>
<button
hx-get="/users/new"
hx-target="#modal-container"
hx-swap="innerHTML"
class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Add User
</button>
</div>
<!-- Barre de recherche -->
<div class="flex gap-4">
<input
type="text"
name="q"
placeholder="Search users..."
hx-get="/users/search"
hx-trigger="input changed delay:300ms"
hx-target="#user-list"
hx-swap="innerHTML"
hx-indicator="#search-spinner"
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
<div id="search-spinner" class="htmx-indicator">
<svg class="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</div>
@UserList(users)
</div>
}
Points Clés :
- Les composants Templ génèrent du code Go à la compilation ; les erreurs de type sont détectées pendant la compilation
- Les expressions conditionnelles
templ.KV()remplacent if/else dans les templates, gardant les templates propres - Les composants se composent via la syntaxe
@ComponentName(), formant un arbre de composants clair - Les attributs HTMX sont écrits directement sur les éléments HTML, ne nécessitant aucun JavaScript supplémentaire
Motif Clé 2 : Interactions Dynamiques HTMX et Mises à Jour Partielles
HTMX implémente les interactions AJAX via des attributs HTML (hx-get, hx-post, hx-swap, etc.). Le serveur retourne des fragments HTML qui remplacent le contenu partiel de la page. Ce motif permet au backend Go de contrôler entièrement la logique de rendu sans aucun framework JavaScript frontend.
// handlers/user_handler.go
package handlers
import (
"net/http"
"github.com/a-h/templ"
"toolsku/components"
"toolsku/repository"
)
// UserHandler gestionnaires HTTP liés aux utilisateurs
type UserHandler struct {
userRepo *repository.UserRepository
}
// NewUserHandler crée un UserHandler
func NewUserHandler(userRepo *repository.UserRepository) *UserHandler {
return &UserHandler{userRepo: userRepo}
}
// List affiche la page de liste des utilisateurs (page complète)
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
users, err := h.userRepo.List(r.Context(), 20, 0)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Vérifier si requête HTMX (mise à jour partielle)
if isHTMXRequest(r) {
templ.Handler(components.UserList(users)).ServeHTTP(w, r)
return
}
// Requête normale retourne la page complète
templ.Handler(components.UserListPage(users)).ServeHTTP(w, r)
}
// Search recherche des utilisateurs (mise à jour partielle HTMX)
func (h *UserHandler) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
var users []components.User
var err error
if query != "" {
users, err = h.userRepo.Search(r.Context(), query)
} else {
users, err = h.userRepo.List(r.Context(), 20, 0)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
templ.Handler(components.UserList(users)).ServeHTTP(w, r)
}
// Create crée un utilisateur (soumission de formulaire HTMX)
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user := components.User{
Name: r.FormValue("name"),
Email: r.FormValue("email"),
Role: r.FormValue("role"),
}
created, err := h.userRepo.Create(r.Context(), user)
if err != nil {
templ.Handler(components.ErrorAlert("Failed to create user: " + err.Error())).ServeHTTP(w, r)
return
}
w.Header().Set("HX-Trigger", `{"userCreated": {"id": "`+created.ID+`"}}`)
templ.Handler(components.UserCard(*created)).ServeHTTP(w, r)
}
// Delete supprime un utilisateur (suppression HTMX)
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if err := h.userRepo.Delete(r.Context(), userID); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// EditForm formulaire d'édition d'utilisateur (modal HTMX)
func (h *UserHandler) EditForm(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
user, err := h.userRepo.Get(r.Context(), userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
templ.Handler(components.UserEditModal(*user)).ServeHTTP(w, r)
}
// Update met à jour un utilisateur (soumission de formulaire HTMX)
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("id")
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := h.userRepo.Get(r.Context(), userID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
user.Name = r.FormValue("name")
user.Email = r.FormValue("email")
user.Role = r.FormValue("role")
updated, err := h.userRepo.Update(r.Context(), *user)
if err != nil {
templ.Handler(components.ErrorAlert("Failed to update user: " + err.Error())).ServeHTTP(w, r)
return
}
w.Header().Set("HX-Trigger", `{"userUpdated": {"id": "`+updated.ID+`"}}`)
templ.Handler(components.UserCard(*updated)).ServeHTTP(w, r)
}
// isHTMXRequest vérifie si la requête provient d'HTMX
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
Points Clés :
isHTMXRequest()distingue les requêtes de page complète des mises à jour partielles ; le même Handler gère les deux scénarios- Les réponses d'événements HTMX
hx-trigger(userCreated,userUpdated) permettent la communication inter-composants hx-swap="outerHTML swap:0.3s"crée des effets d'animation de suppressionhx-indicatoraffiche l'état de chargement de la requête, améliorant l'expérience utilisateur
Motif Clé 3 : Intégration du Stylisme Atomique Tailwind CSS
Tailwind CSS s'intègre naturellement avec Templ — Templ génère du HTML pur, Tailwind traite des noms de classes CSS purs. Grâce au compilateur JIT de Tailwind, seuls les styles utilisés sont inclus, résultant en une sortie CSS minimale.
/* assets/css/app.css - Fichier d'entrée Tailwind */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Styles de composants personnalisés */
@layer components {
.btn-primary {
@apply bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700
transition-colors font-medium;
}
.btn-secondary {
@apply bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200
transition-colors font-medium;
}
.btn-danger {
@apply bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700
transition-colors font-medium;
}
.input-field {
@apply w-full rounded-lg border border-gray-300 px-3 py-2
focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500
placeholder-gray-400;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6
hover:shadow-md transition-shadow;
}
}
/* Styles d'indicateur HTMX */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}
/* Animations de transition HTMX */
.htmx-swapping {
opacity: 0;
transition: opacity 0.3s ease-out;
}
.htmx-settling {
opacity: 1;
transition: opacity 0.3s ease-in;
}
// components/shared.templ - Composants stylisés réutilisables
package components
// BadgeProps propriétés du badge
type BadgeProps struct {
Text string
Color string
}
// Badge composant badge
templ Badge(props BadgeProps) {
<span class={ "px-2.5 py-0.5 rounded-full text-xs font-medium",
badgeColorClass(props.Color) }>
{ props.Text }
</span>
}
func badgeColorClass(color string) string {
colors := map[string]string{
"red": "bg-red-100 text-red-800",
"green": "bg-green-100 text-green-800",
"blue": "bg-blue-100 text-blue-800",
"yellow": "bg-yellow-100 text-yellow-800",
"gray": "bg-gray-100 text-gray-800",
}
if c, ok := colors[color]; ok {
return c
}
return colors["gray"]
}
// StatCard composant carte de statistiques
templ StatCard(title string, value string, change string, changeType string) {
<div class="card">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-500">{ title }</p>
if change != "" {
<span class={ "text-xs font-medium px-2 py-0.5 rounded-full",
templ.KV(changeType == "positive", "bg-green-100 text-green-800",
templ.KV(changeType == "negative", "bg-red-100 text-red-800",
"bg-gray-100 text-gray-800")) }>
{ change }
</span>
}
</div>
<p class="mt-2 text-3xl font-bold text-gray-900">{ value }</p>
</div>
}
// Pagination composant de pagination
templ Pagination(currentPage, totalPages int, baseURL string) {
<div class="flex items-center justify-between mt-6">
<p class="text-sm text-gray-500">
Page { currentPage } of { totalPages }
</p>
<div class="flex space-x-2">
if currentPage > 1 {
<button
hx-get={ baseURL + "?page=1" }
hx-target="#content-area"
hx-swap="innerHTML"
class="btn-secondary text-sm"
>
First
</button>
<button
hx-get={ fmt.Sprintf("%s?page=%d", baseURL, currentPage-1) }
hx-target="#content-area"
hx-swap="innerHTML"
class="btn-secondary text-sm"
>
Previous
</button>
}
if currentPage < totalPages {
<button
hx-get={ fmt.Sprintf("%s?page=%d", baseURL, currentPage+1) }
hx-target="#content-area"
hx-swap="innerHTML"
class="btn-primary text-sm"
>
Next
</button>
}
</div>
</div>
}
Points Clés :
@layer componentsextrait les combinaisons de styles courantes en classes de composants, réduisant la répétition- Les styles d'indicateur HTMX (
.htmx-indicator) garantissent l'affichage correct des états de chargement - Les fonctions Go (
badgeColorClass) gèrent la logique de style dynamique plus clairement que les conditions de template - L'imbrication de
templ.KV()permet la sélection de styles multi-conditions
Motif Clé 4 : Gestion de Formulaires et Retour de Validation
Les formulaires sont l'interaction centrale des applications web. La combinaison Templ + HTMX rend la gestion de formulaires extrêmement concise : validation côté serveur, retour en temps réel et soumission sans rechargement — le tout via des attributs HTML et du code Go.
// components/form.templ
package components
// UserFormProps propriétés du formulaire utilisateur
type UserFormProps struct {
User User
Errors map[string]string
IsEditing bool
}
// UserForm formulaire de création/édition d'utilisateur
templ UserForm(props UserFormProps) {
<form
if props.IsEditing {
hx-put={ "/users/" + props.User.ID }
} else {
hx-post="/users"
}
hx-target="#form-container"
hx-swap="innerHTML"
hx-indicator="#form-spinner"
class="space-y-6"
>
<!-- Champ Nom -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
Name <span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
value={ props.User.Name }
required
hx-post="/users/validate"
hx-trigger="change"
hx-target="#name-error"
hx-swap="innerHTML"
class={ "input-field",
templ.KV(hasError(props.Errors, "name"), "border-red-500 focus:border-red-500 focus:ring-red-500") }
/>
if err, ok := props.Errors["name"]; ok {
<p id="name-error" class="mt-1 text-sm text-red-600">{ err }</p>
} else {
<p id="name-error" class="mt-1 text-sm text-red-600"></p>
}
</div>
<!-- Champ Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email <span class="text-red-500">*</span>
</label>
<input
type="email"
id="email"
name="email"
value={ props.User.Email }
required
hx-post="/users/validate"
hx-trigger="change"
hx-target="#email-error"
hx-swap="innerHTML"
class={ "input-field",
templ.KV(hasError(props.Errors, "email"), "border-red-500 focus:border-red-500 focus:ring-red-500") }
/>
if err, ok := props.Errors["email"]; ok {
<p id="email-error" class="mt-1 text-sm text-red-600">{ err }</p>
} else {
<p id="email-error" class="mt-1 text-sm text-red-600"></p>
}
</div>
<!-- Bouton de soumission -->
<div class="flex items-center space-x-3">
<button type="submit" class="btn-primary flex items-center space-x-2">
<span if={ props.IsEditing }>Update User</span>
<span if={ !props.IsEditing }>Create User</span>
<div id="form-spinner" class="htmx-indicator">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</button>
<a href="/users" class="btn-secondary inline-block">Cancel</a>
</div>
</form>
}
func hasError(errors map[string]string, field string) bool {
_, ok := errors[field]
return ok
}
// handlers/validation_handler.go
package handlers
import (
"net/http"
"net/mail"
"strings"
"github.com/a-h/templ"
"toolsku/components"
)
// Validator validateur de formulaire
type Validator struct {
Errors map[string]string
}
func NewValidator() *Validator {
return &Validator{Errors: make(map[string]string)}
}
func (v *Validator) ValidateField(field, value string, rules ...func(string) string) {
for _, rule := range rules {
if msg := rule(value); msg != "" {
v.Errors[field] = msg
return
}
}
}
func Required(field string) func(string) string {
return func(value string) string {
if strings.TrimSpace(value) == "" {
return field + " is required"
}
return ""
}
}
func EmailFormat() func(string) string {
return func(value string) string {
if _, err := mail.ParseAddress(value); err != nil {
return "Invalid email format"
}
return ""
}
}
func MinLength(min int) func(string) string {
return func(value string) string {
if len(value) < min {
return fmt.Sprintf("Must be at least %d characters", min)
}
return ""
}
}
// ValidateField validation de champ en temps réel (HTMX)
func (h *UserHandler) ValidateField(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
return
}
validator := NewValidator()
if name := r.FormValue("name"); name != "" {
validator.ValidateField("name", name, Required("Name"), MinLength(2))
}
if email := r.FormValue("email"); email != "" {
validator.ValidateField("email", email, Required("Email"), EmailFormat())
}
for field, msg := range validator.Errors {
templ.Handler(components.FieldError(field, msg)).ServeHTTP(w, r)
return
}
w.WriteHeader(http.StatusOK)
}
Points Clés :
hx-trigger="change"permet la validation en temps réel au niveau du champ sans attendre la soumission du formulaire- Le motif Validator (
Required,EmailFormat,MinLength) est composable et réutilisable - Les messages d'erreur s'affichent sous les champs correspondants via les mises à jour partielles HTMX
templ.KV()bascule dynamiquement la couleur de bordure de l'entrée en fonction de l'état d'erreur
Motif Clé 5 : Application CRUD Full-Stack Complète
Intégrez les 4 motifs précédents dans une application CRUD complète avec enregistrement de routes, middleware, opérations de base de données, gestion des erreurs et autres éléments de niveau production.
// main.go - Point d'entrée complet de l'application
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/lib/pq"
"toolsku/handlers"
"toolsku/repository"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("connect database: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("ping database: %v", err)
}
userRepo := repository.NewUserRepository(db)
userHandler := handlers.NewUserHandler(userRepo)
mux := http.NewServeMux()
mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets"))))
mux.HandleFunc("GET /users", userHandler.List)
mux.HandleFunc("GET /users/search", userHandler.Search)
mux.HandleFunc("GET /users/new", userHandler.NewForm)
mux.HandleFunc("POST /users", userHandler.Create)
mux.HandleFunc("GET /users/{id}/edit", userHandler.EditForm)
mux.HandleFunc("PUT /users/{id}", userHandler.Update)
mux.HandleFunc("DELETE /users/{id}", userHandler.Delete)
mux.HandleFunc("POST /users/validate", userHandler.ValidateField)
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/users", http.StatusFound)
})
var handler http.Handler = mux
handler = middlewareLogging(handler)
handler = middlewareRecovery(handler)
handler = middlewareCORS(handler)
server := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Println("shutting down server...")
server.Shutdown(ctx)
}()
log.Printf("server starting on %s", server.Addr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
func middlewareLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
})
}
func middlewareRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func middlewareCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, HX-Request, HX-Target, HX-Trigger")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// repository/user_repository.go - Couche d'opérations de base de données
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
"toolsku/components"
)
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]components.User, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, email, role, created_at, avatar_url
FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("query users: %w", err)
}
defer rows.Close()
var users []components.User
for rows.Next() {
var u components.User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt, &u.AvatarURL); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
func (r *UserRepository) Search(ctx context.Context, query string) ([]components.User, error) {
pattern := "%" + strings.ToLower(query) + "%"
rows, err := r.db.QueryContext(ctx,
`SELECT id, name, email, role, created_at, avatar_url
FROM users WHERE LOWER(name) LIKE $1 OR LOWER(email) LIKE $2
ORDER BY created_at DESC LIMIT 20`,
pattern, pattern,
)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
defer rows.Close()
var users []components.User
for rows.Next() {
var u components.User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt, &u.AvatarURL); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
func (r *UserRepository) Get(ctx context.Context, id string) (*components.User, error) {
var u components.User
err := r.db.QueryRowContext(ctx,
`SELECT id, name, email, role, created_at, avatar_url FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt, &u.AvatarURL)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user not found: %s", id)
}
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
return &u, nil
}
func (r *UserRepository) Create(ctx context.Context, user components.User) (*components.User, error) {
var created components.User
err := r.db.QueryRowContext(ctx,
`INSERT INTO users (name, email, role, avatar_url)
VALUES ($1, $2, $3, $4)
RETURNING id, name, email, role, created_at, avatar_url`,
user.Name, user.Email, user.Role, user.AvatarURL,
).Scan(&created.ID, &created.Name, &created.Email, &created.Role, &created.CreatedAt, &created.AvatarURL)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
return &created, nil
}
func (r *UserRepository) Update(ctx context.Context, user components.User) (*components.User, error) {
var updated components.User
err := r.db.QueryRowContext(ctx,
`UPDATE users SET name = $1, email = $2, role = $3
WHERE id = $4
RETURNING id, name, email, role, created_at, avatar_url`,
user.Name, user.Email, user.Role, user.ID,
).Scan(&updated.ID, &updated.Name, &updated.Email, &updated.Role, &updated.CreatedAt, &updated.AvatarURL)
if err != nil {
return nil, fmt.Errorf("update user: %w", err)
}
return &updated, nil
}
func (r *UserRepository) Delete(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
if n, _ := result.RowsAffected(); n == 0 {
return fmt.Errorf("user not found: %s", id)
}
return nil
}
Points Clés :
- Les motifs de route améliorés de Go 1.22 (
GET /users/{id}) remplacent les bibliothèques de routeur tierces - La chaîne de middleware gère la journalisation, la récupération de panique, CORS et d'autres préoccupations transversales
- Le motif Repository encapsule les opérations de base de données ; les Handlers se concentrent uniquement sur la logique HTTP
- Les requêtes HTMX et les requêtes normales partagent le même Handler, différenciées via
isHTMXRequest()
Pièges Courants
Piège 1 : Oublier d'Échapper les Entrées Utilisateur dans les Composants Templ
// ❌ Incorrect : Affichage direct de l'entrée utilisateur, risque XSS
templ UnsafeOutput(content string) {
<div>{ content }</div>
}
// ✅ Correct : Templ échappe automatiquement en HTML toutes les chaînes par défaut, aucun traitement supplémentaire nécessaire
templ SafeOutput(content string) {
<div>{ content }</div> // Templ échappe automatiquement
}
// Si vous avez vraiment besoin d'une sortie HTML brute, utilisez templ.Raw
templ RawHTML(html string) {
<div>{ templ.Raw(html) }</div> // À utiliser uniquement avec du contenu de confiance
}
Piège 2 : Retourner des Pages Complètes pour les Requêtes HTMX
// ❌ Incorrect : Les requêtes HTMX retournent aussi des pages complètes, gaspillant la bande passante
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
users, _ := h.userRepo.List(r.Context(), 20, 0)
templ.Handler(components.UserListPage(users)).ServeHTTP(w, r)
}
// ✅ Correct : Différencier les requêtes HTMX et les requêtes normales
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
users, _ := h.userRepo.List(r.Context(), 20, 0)
if isHTMXRequest(r) {
templ.Handler(components.UserList(users)).ServeHTTP(w, r)
return
}
templ.Handler(components.UserListPage(users)).ServeHTTP(w, r)
}
Piège 3 : La Concaténation de Classes Tailwind Casse la Détection JIT
// ❌ Incorrect : Concaténation dynamique de classes Tailwind, le JIT ne peut pas les détecter
templ DynamicClass(color string) {
<div class={ "bg-" + color + "-500 text-white" }>Content</div>
}
// ✅ Correct : Utiliser des noms de classes complets pour garantir la détection JIT
templ CorrectClass(color string) {
<div class={ badgeColorClass(color) }>Content</div>
}
func badgeColorClass(color string) string {
switch color {
case "red":
return "bg-red-500 text-white"
case "green":
return "bg-green-500 text-white"
default:
return "bg-gray-500 text-white"
}
}
Piège 4 : Les Événements HTMX Ne Se Déclenchent Pas Correctement
// ❌ Incorrect : Format d'en-tête HX-Trigger incorrect
w.Header().Set("HX-Trigger", "userCreated")
// ✅ Correct : Utiliser le format JSON pour transmettre les données d'événement
w.Header().Set("HX-Trigger", `{"userCreated": {"id": "123"}}`)
Piège 5 : Le Code Généré par Templ N'Est Pas Inclus dans le Processus de Build
# ❌ Incorrect : Oublier d'exécuter templ generate, provoquant l'échec de la compilation Go
go build -o app .
# ✅ Correct : Générer d'abord le code Templ, puis compiler
templ generate && go build -o app .
Dépannage des Erreurs
| Symptôme d'Erreur | Cause Possible | Méthode de Dépannage | Solution |
|---|---|---|---|
| La compilation Templ échoue | Erreur de syntaxe ou inadéquation de type | Vérifier la sortie de templ generate |
Corriger la syntaxe et les types du template |
| Requête HTMX sans réponse | hx-target mal configuré | Vérifier les requêtes réseau du navigateur | Confirmer que l'ID de l'élément cible existe |
| Styles Tailwind manquants | Le JIT n'a pas scanné les noms de classes | Vérifier la configuration de contenu | S'assurer que les fichiers templ sont dans le chemin de scan |
| Soumission de formulaire 404 | Route non enregistrée | Vérifier le chemin d'enregistrement du mux | Confirmer que la méthode HTTP correspond |
| Le modal ne se ferme pas | Logique de fermeture manquante | Vérifier les en-têtes de réponse HTMX | Ajouter un événement de fermeture HX-Trigger |
| Délai de recherche perceptible | Pas de debounce configuré | Vérifier la configuration hx-trigger | Ajouter delay:300ms |
| L'animation de suppression ne fonctionne pas | hx-swap mal configuré | Vérifier les paramètres de swap | Utiliser outerHTML swap:0.3s |
| Fichier CSS trop volumineux | Minification non activée | Vérifier la commande de build | Ajouter le flag --minify |
| Scintillement de la page | Sauts de contenu pendant le chargement HTMX | Vérifier les styles d'indicateur | Ajouter htmx-indicator |
| Validation ne se déclenchant pas | hx-trigger mal configuré | Vérifier le nom de l'événement trigger | Utiliser change ou input |
Optimisation Avancée
1. Rechargement à Chaud du Code Templ
Utilisez air ou cosmtrek/air pour le rechargement à chaud du code Go, combiné avec templ generate --watch pour les mises à jour à chaud des templates. L'expérience de développement rivalise avec le HMR frontend.
2. Intégration des Extensions HTMX
Exploitez les extensions HTMX (response-targets, loading-states, path-params) pour améliorer les capacités d'interaction et implémenter des motifs d'interface plus complexes comme le défilement infini et le tri par glisser-déposer.
3. Mise en Cache et Streaming des Composants
Mettez en cache les résultats de rendu pour les composants statiques en utilisant templ.Handler, et diffusez en streaming les fragments HTML pour les longues listes afin d'améliorer la vitesse du premier contenu utile.
4. Stratégie d'Amélioration Progressive
Ajoutez des alternatives noscript pour les interactions HTMX pour garantir le fonctionnement de l'application lorsque JavaScript est désactivé. Utilisez des attributs d'action standards sur les formulaires, avec HTMX en surcouche.
5. Optimisation du Build et Déploiement
Utilisez esbuild pour regrouper le JavaScript minimal côté client (comme Alpine.js), compilez Go en un binaire unique, et utilisez les builds multi-étapes Docker pour minimiser la taille de l'image.
Comparaison
| Dimension | Templ+HTMX+Tailwind | React+Next.js | Vue+Nuxt |
|---|---|---|---|
| Unification du Langage | ✅ Full-stack Go | ❌ Go+TypeScript | ❌ Go+TypeScript |
| Sécurité de Type | ✅ Vérification à la compilation | ✅ TypeScript | ✅ TypeScript |
| Performance du Premier Rendu | ✅ Rendu côté serveur | ⚠️ Nécessite optimisation SSR | ⚠️ Nécessite optimisation SSR |
| Complexité des Interactions | ⚠️ Moyenne | ✅ Interactions complexes | ✅ Interactions complexes |
| Complexité du Build | ✅ Minimale | ❌ Complexe | ❌ Complexe |
| Courbe d'Apprentissage | ✅ Faible | ❌ Élevée | ⚠️ Moyenne |
| Compatible SEO | ✅ Rendu côté serveur | ✅ Support SSR | ✅ Support SSR |
| Taille du Bundle | ✅ Minuscule | ❌ Volumineux | ⚠️ Moyen |
| Richesse de l'Écosystème | ⚠️ En croissance | ✅ Très riche | ✅ Riche |
Résumé
Templ + HTMX + Tailwind n'a pas vocation à remplacer React ou Vue — c'est un choix plus simple pour les applications qui n'ont pas besoin d'interactions frontend complexes. Quand votre application est principalement du CRUD, que les interactions sont surtout des formulaires et des listes, et que votre équipe est composée principalement de développeurs Go, cette stack vous permet de construire des applications web modernes performantes avec un minimum de code, un minimum de dépendances et un minimum d'étapes de build. Less is more — ce n'est pas qu'une philosophie de design, mais une philosophie d'ingénierie.
Recommandations d'Outils en Ligne
- JSON Formatter — Formatez le JSON de réponse d'API pour une vérification rapide des données des composants Templ
- cURL to Code — Convertissez les commandes cURL en code HTTP Go pour des tests rapides des endpoints HTMX
- Hash Calculator — Calculez les valeurs de hachage des identifiants utilisateur pour la génération d'URL d'avatar et l'anonymisation des données
Essayez ces outils exécutés localement dans le navigateur — aucune inscription requise →