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 → silent LeadSuccess() без записи в БД.

Для страниц сайта аналогично:

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, а не всю страницу.