Go Templ+HTMX+Tailwind на практике: 5 ключевых паттернов для создания современных веб-приложений без фронтенд-фреймворков

前端工程

В 2026 году усталость от фронтенда достигла пика. React 19 Server Components, Next.js 15 App Router, Vue 3.5 Suspense — каждый фреймворк становится всё сложнее. Сообщество Go предлагает принципиально иной ответ: Templ + HTMX + Tailwind. Templ заменяет JSX на типобезопасные Go-шаблоны, HTMX заменяет JavaScript-взаимодействия на HTML-атрибуты, а Tailwind заменяет библиотеки компонентов на атомарный CSS. Вместе они позволяют создавать интерактивные, высокопроизводительные современные веб-приложения без единой строки JavaScript. В этой статье мы разберём 5 ключевых паттернов для освоения этого подхода к фулстек-разработке.

Основные концепции

Концепция Описание Ключевой пакет/инструмент
Компоненты Templ Типобезопасный движок Go-шаблонов с проверкой при компиляции github.com/a-h/templ
Взаимодействия HTMX AJAX-взаимодействия через HTML-атрибуты, без JS htmx.org
Tailwind CSS Атомарный CSS-фреймворк, утилитарный подход tailwindcss
Серверный рендеринг Go-сервер рендерит HTML напрямую, без SPA net/http
Частичные обновления HTMX позволяет обновлять части страницы без полной перезагрузки hx-get/hx-post

Анализ проблемы: 5 болевых точек традиционной фулстек-разработки

Болевая точка 1: Сложность JavaScript-фреймворков вышла из-под контроля

Простое CRUD-приложение требует десятка конфигурационных файлов для React + TypeScript + Webpack + Babel + ESLint. Порог входа для новых разработчиков крайне высок.

Болевая точка 2: Несовпадение типов между фронтендом и бэкендом

Go-структуры бэкенда и TypeScript-интерфейсы фронтенда нужно синхронизировать вручную. Изменения API часто упускаются, вызывая ошибки времени выполнения.

Болевая точка 3: Медленный First Contentful Paint в SPA

Одностраничные приложения должны загрузить огромный объём JavaScript перед рендерингом первого экрана. Это ухудшает SEO и пользовательский опыт.

Болевая точка 4: Сложное управление состоянием

Redux, Pinia, Zustand — в каждом проекте идут споры о том, какую библиотеку управления состоянием использовать. Простые функции переусложняются.

Болевая точка 5: Хрупкий инструментарий сборки

Конфиги Webpack, плагины Vite, обработка PostCSS — любой разрыв в цепочке вызывает ошибки сборки, которые трудно отладить.

Ключевой паттерн 1: Определение компонентов Templ и типобезопасные шаблоны

Templ — это типобезопасный шаблонизатор Go. Он проверяет синтаксис и типы шаблонов при компиляции, полностью исключая ошибки шаблонов времени выполнения. Каждый компонент Templ — это Go-функция, которая может принимать параметры, вызывать другие компоненты и компоновать сложные страницы.

// components/layout.templ
package components

// LayoutProps свойства макета страницы
type LayoutProps struct {
	Title       string
	Description string
}

// Layout базовый компонент макета страницы
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 свойства страницы
type PageProps struct {
	Title       string
	Description string
	Content     templ.Component
}

// Page компонент страницы с макетом
templ Page(props PageProps) {
	@Layout(LayoutProps{
		Title:       props.Title,
		Description: props.Description,
	}) {
		{ props.Content }
	}
}
// components/user.templ
package components

import "time"

// User модель данных пользователя
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 компонент карточки пользователя
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 компонент списка пользователей
templ UserList(users []User) {
	<div id="user-list" class="space-y-4">
		for _, user := range users {
			@UserCard(user)
		}
	</div>
}

// UserListPage страница списка пользователей
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>

		<!-- Панель поиска -->
		<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>
}

Ключевые выводы:

  • Компоненты Templ генерируют Go-код при компиляции; ошибки типов обнаруживаются во время компиляции
  • Условные выражения templ.KV() заменяют if/else в шаблонах, сохраняя их чистоту
  • Компоненты компонуются через синтаксис @ComponentName(), образуя ясное дерево компонентов
  • Атрибуты HTMX пишутся прямо на HTML-элементах, не требуя дополнительного JavaScript

Ключевой паттерн 2: Динамические взаимодействия HTMX и частичные обновления

HTMX реализует AJAX-взаимодействия через HTML-атрибуты (hx-get, hx-post, hx-swap и др.). Сервер возвращает HTML-фрагменты, которые заменяют части содержимого страницы. Этот паттерн позволяет Go-бэкенду полностью контролировать логику рендеринга без какого-либо фронтенд JavaScript-фреймворка.

// handlers/user_handler.go
package handlers

import (
	"net/http"

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

// UserHandler HTTP-обработчики для пользователей
type UserHandler struct {
	userRepo *repository.UserRepository
}

// NewUserHandler создаёт UserHandler
func NewUserHandler(userRepo *repository.UserRepository) *UserHandler {
	return &UserHandler{userRepo: userRepo}
}

// List отображает страницу списка пользователей (полная страница)
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
	}

	// Проверка HTMX-запроса (частичное обновление)
	if isHTMXRequest(r) {
		templ.Handler(components.UserList(users)).ServeHTTP(w, r)
		return
	}

	// Обычный запрос возвращает полную страницу
	templ.Handler(components.UserListPage(users)).ServeHTTP(w, r)
}

// Search поиск пользователей (частичное обновление 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 создаёт пользователя (отправка формы 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 удаляет пользователя (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 форма редактирования пользователя (модальное окно 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 обновляет пользователя (отправка формы 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 проверяет, является ли запрос от HTMX
func isHTMXRequest(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}

Ключевые выводы:

  • isHTMXRequest() различает полные запросы страницы и частичные обновления; один и тот же Handler обрабатывает оба сценария
  • Событийные ответы HTMX hx-trigger (userCreated, userUpdated) обеспечивают межкомпонентное взаимодействие
  • hx-swap="outerHTML swap:0.3s" создаёт эффекты анимации удаления
  • hx-indicator показывает состояние загрузки запроса, улучшая пользовательский опыт

Ключевой паттерн 3: Интеграция атомарной стилизации Tailwind CSS

Tailwind CSS естественно интегрируется с Templ — Templ генерирует чистый HTML, Tailwind обрабатывает чистые CSS-классы. Через JIT-компилятор Tailwind в сборку попадают только используемые стили, что даёт минимальный CSS-вывод.

/* assets/css/app.css - Входной файл Tailwind */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* Пользовательские стили компонентов */
@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 */
.htmx-indicator {
	display: none;
}
.htmx-request .htmx-indicator {
	display: inline-block;
}
.htmx-request.htmx-indicator {
	display: inline-block;
}

/* Переходные анимации HTMX */
.htmx-swapping {
	opacity: 0;
	transition: opacity 0.3s ease-out;
}
.htmx-settling {
	opacity: 1;
	transition: opacity 0.3s ease-in;
}
// components/shared.templ - Повторно используемые стилизованные компоненты
package components

// BadgeProps свойства значка
type BadgeProps struct {
	Text  string
	Color string
}

// 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 компонент карточки статистики
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 компонент пагинации
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>
}

Ключевые выводы:

  • @layer components извлекает общие комбинации стилей как классы компонентов, уменьшая дублирование
  • Стили индикатора HTMX (.htmx-indicator) обеспечивают корректное отображение состояний загрузки
  • Go-функции (badgeColorClass) обрабатывают динамическую стилевую логику яснее, чем условные конструкции в шаблонах
  • Вложенные вызовы templ.KV() позволяют выбирать стили по нескольким условиям

Ключевой паттерн 4: Обработка форм и валидация с обратной связью

Формы — основное взаимодействие веб-приложений. Комбинация Templ + HTMX делает обработку форм предельно лаконичной: серверная валидация, обратная связь в реальном времени и отправка без перезагрузки — всё через HTML-атрибуты и Go-код.

// components/form.templ
package components

// UserFormProps свойства формы пользователя
type UserFormProps struct {
	User      User
	Errors    map[string]string
	IsEditing bool
}

// UserForm форма создания/редактирования пользователя
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"
	>
		<!-- Поле имени -->
		<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 -->
		<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>

		<!-- Кнопка отправки -->
		<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 валидатор форм
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 валидация поля в реальном времени (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)
}

Ключевые выводы:

  • hx-trigger="change" включает валидацию полей в реальном времени без ожидания отправки формы
  • Паттерн Validator (Required, EmailFormat, MinLength) компонуем и переиспользуем
  • Сообщения об ошибках отображаются под соответствующими полями через частичные обновления HTMX
  • templ.KV() динамически переключает цвет рамки ввода в зависимости от состояния ошибки

Ключевой паттерн 5: Полноценное фулстек CRUD-приложение

Интегрируем предыдущие 4 паттерна в полноценное CRUD-приложение с регистрацией маршрутов, промежуточным ПО, операциями с базой данных, обработкой ошибок и другими элементами продакшен-уровня.

// main.go - Полная точка входа приложения
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 - Слой операций с базой данных
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
}

Ключевые выводы:

  • Улучшенные шаблоны маршрутов Go 1.22 (GET /users/{id}) заменяют сторонние библиотеки маршрутизации
  • Цепочка промежуточного ПО обрабатывает логирование, восстановление после паники, CORS и другие сквозные задачи
  • Паттерн Repository инкапсулирует операции с базой данных; Handlers фокусируются только на HTTP-логике
  • HTMX-запросы и обычные запросы используют один и тот же Handler, различаясь через isHTMXRequest()

Частые подводные камни

Подводный камень 1: Забывают экранировать пользовательский ввод в компонентах Templ

// ❌ Неправильно: Прямой вывод пользовательского ввода, риск XSS
templ UnsafeOutput(content string) {
	<div>{ content }</div>
}

// ✅ Правильно: Templ HTML-экранирует все строки по умолчанию, дополнительная обработка не нужна
templ SafeOutput(content string) {
	<div>{ content }</div>  // Templ автоматически экранирует
}

// Если действительно нужен вывод сырого HTML, используйте templ.Raw
templ RawHTML(html string) {
	<div>{ templ.Raw(html) }</div>  // Используйте только с доверенным содержимым
}

Подводный камень 2: Возврат полных страниц для HTMX-запросов

// ❌ Неправильно: HTMX-запросы также возвращают полные страницы, расходуя трафик
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)
}

// ✅ Правильно: Различайте HTMX и обычные запросы
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)
}

Подводный камень 3: Конкатенация классов Tailwind нарушает обнаружение JIT

// ❌ Неправильно: Динамическая конкатенация классов Tailwind, JIT не может их обнаружить
templ DynamicClass(color string) {
	<div class={ "bg-" + color + "-500 text-white" }>Content</div>
}

// ✅ Правильно: Используйте полные имена классов для обеспечения обнаружения 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"
	}
}

Подводный камень 4: События HTMX не срабатывают корректно

// ❌ Неправильно: Некорректный формат заголовка HX-Trigger
w.Header().Set("HX-Trigger", "userCreated")

// ✅ Правильно: Используйте формат JSON для передачи данных события
w.Header().Set("HX-Trigger", `{"userCreated": {"id": "123"}}`)

Подводный камень 5: Сгенерированный код Templ не включён в процесс сборки

# ❌ Неправильно: Забыли запустить templ generate, что вызывает ошибку компиляции Go
go build -o app .

# ✅ Правильно: Сначала сгенерируйте код Templ, затем компилируйте
templ generate && go build -o app .

Устранение ошибок

Симптом ошибки Возможная причина Метод диагностики Решение
Ошибка компиляции Templ Синтаксическая ошибка или несовпадение типов Проверьте вывод templ generate Исправьте синтаксис и типы шаблона
HTMX-запрос без ответа hx-target настроен некорректно Проверьте сетевые запросы в браузере Убедитесь, что ID целевого элемента существует
Отсутствуют стили Tailwind JIT не просканировал имена классов Проверьте конфигурацию content Убедитесь, что файлы templ в пути сканирования
404 при отправке формы Маршрут не зарегистрирован Проверьте путь регистрации в mux Убедитесь, что HTTP-метод совпадает
Модальное окно не закрывается Отсутствует логика закрытия Проверьте заголовки ответа HTMX Добавьте событие закрытия HX-Trigger
Заметная задержка поиска Не установлен debounce Проверьте конфигурацию hx-trigger Добавьте delay:300ms
Анимация удаления не работает hx-swap настроен некорректно Проверьте параметры swap Используйте outerHTML swap:0.3s
CSS-файл слишком большой Минификация не включена Проверьте команду сборки Добавьте флаг --minify
Мерцание страницы Скачки содержимого при загрузке HTMX Проверьте стили индикатора Добавьте htmx-indicator
Валидация не срабатывает hx-trigger настроен некорректно Проверьте имя события триггера Используйте change или input

Продвинутая оптимизация

1. Горячая перезагрузка кода Templ

Используйте air или cosmtrek/air для горячей перезагрузки Go-кода в сочетании с templ generate --watch для горячих обновлений шаблонов. Опыт разработки сопоставим с HMR фронтенда.

2. Интеграция расширений HTMX

Используйте расширения HTMX (response-targets, loading-states, path-params) для расширения интерактивных возможностей и реализации более сложных UI-паттернов, таких как бесконечная прокрутка и сортировка перетаскиванием.

3. Кэширование и потоковая передача компонентов

Кэшируйте результаты рендеринга статических компонентов с помощью templ.Handler и передавайте HTML-фрагменты потоком для длинных списков, чтобы ускорить первый содержательный рендеринг.

4. Стратегия прогрессивного улучшения

Добавляйте noscript-заглушки для HTMX-взаимодействий, чтобы приложение работало при отключённом JavaScript. Используйте стандартные атрибуты action на формах, а HTMX добавляет улучшения поверх.

5. Оптимизация сборки и развёртывания

Используйте esbuild для сборки минимального клиентского JavaScript (например, Alpine.js), скомпилируйте Go в единый бинарный файл и используйте многоэтапные сборки Docker для минимизации размера образа.

Сравнение

Измерение Templ+HTMX+Tailwind React+Next.js Vue+Nuxt
Унификация языка ✅ Полный стек на Go ❌ Go+TypeScript ❌ Go+TypeScript
Типобезопасность ✅ Проверка при компиляции ✅ TypeScript ✅ TypeScript
Производительность первого рендеринга ✅ Серверный рендеринг ⚠️ Требуется оптимизация SSR ⚠️ Требуется оптимизация SSR
Сложность взаимодействий ⚠️ Средняя ✅ Сложные взаимодействия ✅ Сложные взаимодействия
Сложность сборки ✅ Минимальная ❌ Сложная ❌ Сложная
Кривая обучения ✅ Низкая ❌ Высокая ⚠️ Средняя
Дружественность к SEO ✅ Серверный рендеринг ✅ Поддержка SSR ✅ Поддержка SSR
Размер бандла ✅ Крошечный ❌ Большой ⚠️ Средний
Богатство экосистемы ⚠️ Растёт ✅ Очень богатая ✅ Богатая

Заключение

Templ + HTMX + Tailwind не призван заменить React или Vue — это более простой выбор для приложений, которым не нужны сложные фронтенд-взаимодействия. Когда ваше приложение в основном CRUD, взаимодействия — это преимущественно формы и списки, а ваша команда состоит в основном из Go-разработчиков, этот стек позволяет создавать высокопроизводительные современные веб-приложения с минимальным объёмом кода, минимальным количеством зависимостей и минимальным числом шагов сборки. Меньше — значит больше — это не просто философия дизайна, но и инженерная философия.

Рекомендации онлайн-инструментов

  • JSON Formatter — Форматирование JSON ответов API для быстрой проверки данных компонентов Templ
  • cURL to Code — Конвертация команд cURL в Go HTTP-код для быстрого тестирования HTMX-эндпоинтов
  • Hash Calculator — Вычисление хеш-значений ID пользователей для генерации URL аватаров и анонимизации данных

Попробуйте эти локальные браузерные инструменты — регистрация не требуется →

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