Go Templ+HTMX+Tailwind Full-Stack in Practice: 5 Core Patterns for Building Modern Web Apps Without Frontend Frameworks

前端工程

In 2026, frontend fatigue has peaked. React 19's Server Components, Next.js 15's App Router, Vue 3.5's Suspense — every framework keeps getting more complex. The Go community offers a radically different answer: Templ + HTMX + Tailwind. Templ replaces JSX with type-safe Go templates, HTMX replaces JavaScript interactions with HTML attributes, and Tailwind replaces component libraries with atomic CSS. Combined, you can build interactive, high-performance modern web applications without writing a single line of JavaScript. This article walks you through 5 core patterns to master this full-stack development approach.

Core Concepts

Concept Description Key Package/Tool
Templ Components Type-safe Go template engine with compile-time checking github.com/a-h/templ
HTMX Interactions AJAX interactions via HTML attributes, no JS required htmx.org
Tailwind CSS Atomic CSS framework, utility-first tailwindcss
Server-Side Rendering Go server renders HTML directly, no SPA needed net/http
Partial Updates HTMX enables page partial refreshes without full reload hx-get/hx-post

Problem Analysis: 5 Pain Points of Traditional Full-Stack Development

Pain Point 1: JavaScript Framework Complexity Out of Control

A simple CRUD app requires a dozen config files for React + TypeScript + Webpack + Babel + ESLint. The onboarding cost for new developers is extremely high.

Pain Point 2: Frontend-Backend Type Inconsistency

Backend Go structs and frontend TypeScript interfaces need manual synchronization. API changes are often missed, causing runtime errors.

Pain Point 3: Slow SPA First Contentful Paint

Single-page applications must load massive JavaScript before rendering the first screen. This hurts SEO and degrades user experience.

Pain Point 4: Complex State Management

Redux, Pinia, Zustand — every project debates which state management library to use. Simple features get over-engineered.

Pain Point 5: Fragile Build Toolchain

Webpack configs, Vite plugins, PostCSS processing — any broken link in the chain causes build failures that are hard to debug.

Core Pattern 1: Templ Component Definition and Type-Safe Templates

Templ is Go's type-safe template engine. It checks template syntax and types at compile time, completely eliminating runtime template errors. Each Templ component is a Go function that can accept parameters, call other components, and compose into complex pages.

// components/layout.templ
package components

// LayoutProps page layout properties
type LayoutProps struct {
	Title       string
	Description string
}

// Layout base page layout component
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 page properties
type PageProps struct {
	Title       string
	Description string
	Content     templ.Component
}

// Page page component with layout
templ Page(props PageProps) {
	@Layout(LayoutProps{
		Title:       props.Title,
		Description: props.Description,
	}) {
		{ props.Content }
	}
}
// components/user.templ
package components

import "time"

// User user data model
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 user card component
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 user list component
templ UserList(users []User) {
	<div id="user-list" class="space-y-4">
		for _, user := range users {
			@UserCard(user)
		}
	</div>
}

// UserListPage user list page
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>

		<!-- Search bar -->
		<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>
}

Key Takeaways:

  • Templ components generate Go code at compile time; type errors are caught during compilation
  • templ.KV() conditional expressions replace if/else in templates, keeping templates clean
  • Components compose via @ComponentName() syntax, forming a clear component tree
  • HTMX attributes are written directly on HTML elements, requiring no additional JavaScript

Core Pattern 2: HTMX Dynamic Interactions and Partial Updates

HTMX implements AJAX interactions through HTML attributes (hx-get, hx-post, hx-swap, etc.). The server returns HTML fragments that replace partial page content. This pattern lets the Go backend fully control rendering logic without any frontend JavaScript framework.

// handlers/user_handler.go
package handlers

import (
	"net/http"

	"github.com/a-h/templ"
	"toolsku/components"
	"toolsku/repository"
)

// UserHandler user-related HTTP handlers
type UserHandler struct {
	userRepo *repository.UserRepository
}

// NewUserHandler creates UserHandler
func NewUserHandler(userRepo *repository.UserRepository) *UserHandler {
	return &UserHandler{userRepo: userRepo}
}

// List displays user list page (full page)
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
	}

	// Check if HTMX request (partial update)
	if isHTMXRequest(r) {
		templ.Handler(components.UserList(users)).ServeHTTP(w, r)
		return
	}

	// Regular request returns full page
	templ.Handler(components.UserListPage(users)).ServeHTTP(w, r)
}

// Search searches users (HTMX partial update)
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 creates a user (HTMX form submission)
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 deletes a user (HTMX delete)
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 edit user form (HTMX modal)
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 updates a user (HTMX form submission)
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 checks if the request is from HTMX
func isHTMXRequest(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}

Key Takeaways:

  • isHTMXRequest() distinguishes full page requests from partial updates; same Handler handles both scenarios
  • HTMX hx-trigger event responses (userCreated, userUpdated) enable cross-component communication
  • hx-swap="outerHTML swap:0.3s" creates delete animation effects
  • hx-indicator shows request loading state, improving user experience

Core Pattern 3: Tailwind CSS Atomic Styling Integration

Tailwind CSS integrates naturally with Templ — Templ generates pure HTML, Tailwind processes pure CSS class names. Through Tailwind's JIT compiler, only used styles are bundled, resulting in minimal CSS output.

/* assets/css/app.css - Tailwind entry file */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Custom component styles */
@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;
	}
}

/* HTMX indicator styles */
.htmx-indicator {
	display: none;
}
.htmx-request .htmx-indicator {
	display: inline-block;
}
.htmx-request.htmx-indicator {
	display: inline-block;
}

/* HTMX transition animations */
.htmx-swapping {
	opacity: 0;
	transition: opacity 0.3s ease-out;
}
.htmx-settling {
	opacity: 1;
	transition: opacity 0.3s ease-in;
}
// components/shared.templ - Reusable styled components
package components

// BadgeProps badge properties
type BadgeProps struct {
	Text  string
	Color string
}

// Badge badge component
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 statistics card component
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 pagination component
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>
}

Key Takeaways:

  • @layer components extracts common style combinations as component classes, reducing repetition
  • HTMX indicator styles (.htmx-indicator) ensure loading states display correctly
  • Go functions (badgeColorClass) handle dynamic style logic more clearly than template conditionals
  • templ.KV() nesting enables multi-condition style selection

Core Pattern 4: Form Handling and Validation Feedback

Forms are the core interaction of web applications. The Templ + HTMX combination makes form handling extremely concise: server-side validation, real-time feedback, and no-refresh submission — all through HTML attributes and Go code.

// components/form.templ
package components

// UserFormProps user form properties
type UserFormProps struct {
	User      User
	Errors    map[string]string
	IsEditing bool
}

// UserForm user create/edit form
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"
	>
		<!-- Name field -->
		<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>

		<!-- Email field -->
		<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>

		<!-- Submit button -->
		<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 form validator
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 real-time field validation (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)
}

Key Takeaways:

  • hx-trigger="change" enables field-level real-time validation without waiting for form submission
  • Validator pattern (Required, EmailFormat, MinLength) is composable and reusable
  • Error messages display below corresponding fields via HTMX partial updates
  • templ.KV() dynamically switches input border color based on error state

Core Pattern 5: Complete Full-Stack CRUD Application

Integrate the previous 4 patterns into a complete CRUD application with route registration, middleware, database operations, error handling, and other production-grade elements.

// main.go - Complete application entry point
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 - Database operations layer
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
}

Key Takeaways:

  • Go 1.22 enhanced route patterns (GET /users/{id}) replace third-party router libraries
  • Middleware chain handles logging, panic recovery, CORS, and other cross-cutting concerns
  • Repository pattern encapsulates database operations; Handlers focus only on HTTP logic
  • HTMX requests and regular requests share the same Handler, differentiated via isHTMXRequest()

Common Pitfalls

Pitfall 1: Forgetting to Escape User Input in Templ Components

// ❌ Wrong: Directly outputting user input, XSS risk
templ UnsafeOutput(content string) {
	<div>{ content }</div>
}

// ✅ Correct: Templ HTML-escapes all strings by default, no extra handling needed
templ SafeOutput(content string) {
	<div>{ content }</div>  // Templ auto-escapes
}

// If you truly need raw HTML output, use templ.Raw
templ RawHTML(html string) {
	<div>{ templ.Raw(html) }</div>  // Only use with trusted content
}

Pitfall 2: Returning Full Pages for HTMX Requests

// ❌ Wrong: HTMX requests also return full pages, wasting bandwidth
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: Differentiate between HTMX and regular requests
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)
}

Pitfall 3: Tailwind Class Concatenation Breaks JIT Detection

// ❌ Wrong: Dynamically concatenating Tailwind classes, JIT can't detect them
templ DynamicClass(color string) {
	<div class={ "bg-" + color + "-500 text-white" }>Content</div>
}

// ✅ Correct: Use complete class names to ensure JIT detection
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"
	}
}

Pitfall 4: HTMX Events Not Triggering Correctly

// ❌ Wrong: Incorrect HX-Trigger header format
w.Header().Set("HX-Trigger", "userCreated")

// ✅ Correct: Use JSON format to pass event data
w.Header().Set("HX-Trigger", `{"userCreated": {"id": "123"}}`)

Pitfall 5: Templ Generated Code Not Included in Build Process

# ❌ Wrong: Forgetting to run templ generate, causing Go compilation to fail
go build -o app .

# ✅ Correct: Generate Templ code first, then compile
templ generate && go build -o app .

Error Troubleshooting

Error Symptom Possible Cause Troubleshooting Method Solution
Templ compilation fails Syntax error or type mismatch Check templ generate output Fix template syntax and types
HTMX request no response hx-target not set correctly Check browser network requests Confirm target element ID exists
Tailwind styles missing JIT didn't scan class names Check content configuration Ensure templ files are in scan path
Form submit 404 Route not registered Check mux registration path Confirm HTTP method matches
Modal won't close Missing close logic Check HTMX response headers Add HX-Trigger close event
Search delay noticeable No debounce set Check hx-trigger configuration Add delay:300ms
Delete animation not working hx-swap misconfigured Check swap parameters Use outerHTML swap:0.3s
CSS file too large Minify not enabled Check build command Add --minify flag
Page flickering Content jumps during HTMX load Check indicator styles Add htmx-indicator
Validation not triggering hx-trigger misconfigured Check trigger event name Use change or input

Advanced Optimization

1. Templ Code Hot Reload

Use air or cosmtrek/air for Go code hot reload, combined with templ generate --watch for template hot updates. Development experience rivals frontend HMR.

2. HTMX Extension Integration

Leverage HTMX extensions (response-targets, loading-states, path-params) to enhance interaction capabilities and implement more complex UI patterns like infinite scroll and drag-and-drop sorting.

3. Component Caching and Streaming

Cache rendering results for static components using templ.Handler, and stream HTML fragments for long lists to improve first contentful paint speed.

4. Progressive Enhancement Strategy

Add noscript fallbacks for HTMX interactions to ensure the app works when JavaScript is disabled. Use standard action attributes on forms, with HTMX enhancing on top.

5. Build Optimization and Deployment

Use esbuild to bundle minimal client-side JavaScript (like Alpine.js), compile Go into a single binary, and use Docker multi-stage builds to minimize image size.

Comparison

Dimension Templ+HTMX+Tailwind React+Next.js Vue+Nuxt
Language Unification ✅ Full-stack Go ❌ Go+TypeScript ❌ Go+TypeScript
Type Safety ✅ Compile-time checking ✅ TypeScript ✅ TypeScript
First Paint Performance ✅ Server-rendered ⚠️ Needs SSR optimization ⚠️ Needs SSR optimization
Interaction Complexity ⚠️ Medium ✅ Complex interactions ✅ Complex interactions
Build Complexity ✅ Minimal ❌ Complex ❌ Complex
Learning Curve ✅ Low ❌ High ⚠️ Medium
SEO Friendly ✅ Server-rendered ✅ SSR support ✅ SSR support
Bundle Size ✅ Tiny ❌ Large ⚠️ Medium
Ecosystem Richness ⚠️ Growing ✅ Very rich ✅ Rich

Summary

Templ + HTMX + Tailwind isn't meant to replace React or Vue — it's a simpler choice for applications that don't need complex frontend interactions. When your app is primarily CRUD, interactions are mainly forms and lists, and your team consists mainly of Go developers, this stack lets you build high-performance modern web applications with minimal code, minimal dependencies, and minimal build steps. Less is more — this is not just a design philosophy, but an engineering philosophy.

Online Tool Recommendations

  • JSON Formatter — Format API response JSON for quick Templ component data verification
  • cURL to Code — Convert cURL commands to Go HTTP code for quick HTMX endpoint testing
  • Hash Calculator — Calculate user ID hash values for avatar URL generation and data anonymization

Try these browser-local tools — no sign-up required →

#Go Templ#HTMX#Tailwind CSS#全栈开发#2026#前端工程