templ + htmx + PicoCSS: серверные фрагменты вместо SPA
GotemplhtmxPicoCSSEcho
Сайт - классический MPA, но без перезагрузок страниц. Вместо React/Vue - templ (компоненты на Go) + htmx (фрагменты HTML) + PicoCSS (стили по тегам). Бандл JS - один htmx.org@2.0.10.min.js ~15KB.
templ: HTML как Go-код
templ компилирует .templ в .go на этапе сборки, а не в рантайме:
FROM ghcr.io/a-h/templ:latest AS templ
COPY --chown=65532:65532 . /app
RUN ["templ", "generate"]
FROM golang:1.26-alpine AS builder
COPY --from=templ /app /app
RUN CGO_ENABLED=1 go build -o bin/tashirka cmd/tashirka/main.go
Локально то же через Makefile:
build-bin:
@go tool templ generate && go build -ldflags="-s -w" -o bin/tashirka cmd/tashirka/main.go
dev:
@go tool air # air пересобирает templ на лету
Файл view.templ выглядит как HTML с Go-вставками:
templ Base(title, description, url, image string) {
<!DOCTYPE html>
<html lang="ru">
<head>
<title>{ title }</title>
<meta name="description" content={ description }/>
<link rel="stylesheet" href="/static/css/pico@2.min.css"/>
</head>
<body hx-boost="true" hx-target="#main" hx-indicator="#top-loading-bar">
<div id="top-loading-bar" class="htmx-indicator"></div>
@Nav()
<main><section>{ children... }</section></main>
<script src="/static/js/htmx.org@2.0.10.min.js"></script>
</body>
</html>
}
templ Nav() {
<nav>
<ul><li><a href="/" class="contrast"><strong>tashirka</strong></a></li></ul>
<ul class="nav-desktop">
<li><a href="/about">Обо мне</a></li>
<li><a href="/project">Проекты</a></li>
<li><a href="/service">Услуги</a></li>
<li><a href="/post">Статьи</a></li>
</ul>
</nav>
}
После templ generate это обычный Go-пакет internal/core/view/view_templ.go с func Base(...) templ.Component. Никакого html/template с ParseFiles и reflect - всё типизировано.
PicoCSS: стили через семантику
Никаких утилитарных классов Tailwind. PicoCSS стилизует теги напрямую:
<nav>,<article>,<form>,<input>,<button>,<hgroup>- уже красивы без классов..contrast,.hero,.container- минимум кастома вstatic/css/main.css.<a role="button">- ссылка как кнопка без JS.
templ Hero() {
<section class="hero">
<hgroup>
<h1>Ришат Шарафиев</h1>
<p class="hero-subtitle">Веб-разработчик</p>
</hgroup>
@Contact() // <a href="/contact" role="button" class="contrast">
</section>
}
Результат - CSS <30KB, нет сборки PostCSS, нет PurgeCSS.
htmx: фрагменты вместо JSON
Главный трюк - hx-boost="true" на <body> в Base. Любой <a href> и <form> теперь работает через AJAX и меняет только #main:
<body hx-boost="true" hx-target="#main" hx-indicator="#top-loading-bar">
Форма лидов - точечный пример:
templ LeadForm(form model.LeadForm) {
<form hx-post="/lead" hx-target="this" hx-swap="outerHTML" class="lead-form">
<input type="hidden" name="page_url" value={ form.PageURL }/>
<input type="hidden" name="utm_source" value={ form.UTM.Source }/>
<!-- honeypot для ботов -->
<input type="text" name="website" value="" style="display:none" aria-hidden="true"/>
if form.Error != "" {
<p><small>{ form.Error }</small></p>
}
<label>Имя<input type="text" name="name" value={ form.Name } required/></label>
<label>Способ связи
<select name="contact_method" required>
<option value="Telegram">Telegram</option>
<option value="Email">Email</option>
</select>
</label>
<label>Контакт<input type="text" name="contact" id="contact-input" required/></label>
<label>Сообщение<textarea name="message" required>{ form.Message }</textarea></label>
<label><input type="checkbox" name="agree" value="true" required/> Согласен</label>
<button type="submit" class="contrast">Отправить заявку</button>
</form>
}
hx-post="/lead" + hx-target="this" + hx-swap="outerHTML" - форма отправляется POST и заменяет сама себя на ответ сервера. Никакого fetch, JSON, useState.
Handler возвращает фрагмент
func (h *Lead) CreateLead(c echo.Context) error {
form := model.LeadForm{
Name: c.FormValue("name"), ContactMethod: c.FormValue("contact_method"),
Contact: c.FormValue("contact"), PageURL: c.FormValue("page_url"),
Message: c.FormValue("message"), Agree: c.FormValue("agree") == "true",
}
if c.FormValue("website") != "" { // honeypot
return core_view.RenderTemplate(c, lead_view.LeadSuccess())
}
_, err := h.s.Create(c.Request().Context(), form)
if err != nil {
switch {
case errors.Is(err, lead_service.ErrNameRequired),
errors.Is(err, lead_service.ErrContactRequired),
errors.Is(err, lead_service.ErrConsentRequired):
form.Error = err.Error()
return core_view.RenderTemplate(c, lead_view.LeadForm(form)) // форма с ошибкой
default:
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}
}
return core_view.RenderTemplate(c, lead_view.LeadSuccess()) // <article>Спасибо!</article>
}
func RenderTemplate(c echo.Context, cmp templ.Component) error {
c.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML)
return cmp.Render(c.Request().Context(), c.Response().Writer)
}
- Успех →
LeadSuccess()(<article><h3>Заявка отправлена</h3></article>+ метрикаym reachGoal). - Ошибка валидации → та же
LeadFormсform.Errorвнутри<p><small>. - Превышен лимит →
LeadRateLimited()(отдельный фрагмент,429). - Бот заполнил honeypot
website→ silentLeadSuccess()без записи в БД.
Для страниц сайта аналогично:
func (h *Page) PostDetail(c echo.Context) error {
post, err := h.s.GetPost(c.Request().Context(), c.Param("slug"))
if post == nil { return view.RenderTemplate(c, site_view.PostNotFoundPage()) }
return view.RenderTemplate(c, site_view.PostPage(post))
}
LeadForm вставляется как templ.Component через абстракцию, а не конкретный тип:
type LeadFormProvider interface { Form(c echo.Context) templ.Component }
func (h *Page) Contact(c echo.Context) error {
return view.RenderTemplate(c, site_view.ContactPage(h.leadForms.Form(c)))
}
Модули site и lead не импортируют друг друга.
Защита и UX мелочи
select onchangeменяетplaceholderконтакта без JS-фреймворка:Telegram → @username,Email → name@example.com.maxlength="100/2000"- клиентский лимит дублирует серверныйstrings.TrimSpace+ sentinel-ошибки.hx-indicator="#top-loading-bar"- прогресс-бар сверху на время запроса.- CSP в
Base:default-src 'none'; script-src 'self' https://mc.yandex.ru-htmxработает безunsafe-eval.
Проверка
make check # go tool templ generate && go fmt ./... && go test ./...
make dev
curl -s localhost:8000/contact | grep 'hx-post="/lead"' # форма с htmx
curl -s -X POST localhost:8000/lead -d "name=Test&contact_method=Email&contact=a@b.c&message=hi&agree=true" | grep "Заявка"
curl -s localhost:8000/post/007-layered-architecture | grep '<article'
Итого
templ даёт типизацию шаблонов, htmx - SPA-ощущение без JS, PicoCSS - дизайн без сборки CSS. Handler всегда отдаёт templ.Component + 200, а не JSON - браузер меняет один div, а не всю страницу.