DEV Community

Anton Makasin
Anton Makasin

Posted on

Why We Chose Templ Over html/template (and What We Learned)

Go's standard html/template works well. It is in the standard library, is well documented, and provides contextual escaping. We used it before switching to Templ. Here's why.

The Problem With html/template

As the codebase grew, we hit recurring issues:

  1. Template mistakes surface at runtime. Write {{.Titl}} instead of {{.Title}} and template execution returns an error. It is not normally a panic, but it is still later feedback than a compiler error.

  2. Weaker editor support. Support varies by editor and plugin, but field completion, navigation, and refactoring are less reliable across the boundary between Go and template text.

  3. Runtime data contracts. The template expects particular fields and methods, but the Go compiler does not type-check those references. Rename a field and tests—or a request that executes the template—must catch it.

What Templ Gives Us

Templ files use their own Go-like syntax. The generator turns them into Go source, so component parameters and expressions are type-checked:

// templates/card.templ
templ CatalogCard(card CatalogCardView) {
    <article class="card">
        <img src={ card.Image } alt={ card.Title } />
        <h3>{ card.Title }</h3>
        <span class="price">{ FormatPrice(card.Price, card.Currency) }</span>
    </article>
}
Enter fullscreen mode Exit fullscreen mode

If CatalogCardView has no Title field, generation or compilation fails. If FormatPrice changes its signature, compilation fails. Templ's editor tooling also provides syntax support and autocomplete; the file itself is not literally ordinary Go.

The Generation Step

Templ generates *_templ.go files from .templ files. This extra step is the main friction point:

templ generate
Enter fullscreen mode Exit fullscreen mode

Never edit generated files manually: the next generation will overwrite them.

Multi-Container Deduplication

Several development services share the same bind-mounted templates. Letting each process generate the same output is redundant and creates concurrent writers. Our pre-build hook resolves the repository root, checks sources under internal/ and cmd/, and checks for missing generated outputs. It performs the check both before and after acquiring one shared lock:

#!/bin/sh
set -eu

ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
STAMP_DIR="$ROOT/.dev"
STAMP="$STAMP_DIR/templ.stamp"
LOCK="$STAMP_DIR/templ.lock"
mkdir -p "$STAMP_DIR"

needs_generate() {
  [ ! -f "$STAMP" ] && return 0

  newer=$(find "$ROOT/internal" "$ROOT/cmd" \
    -name '*.templ' -newer "$STAMP" -print -quit 2>/dev/null || true)
  [ -n "$newer" ] && return 0

  missing=$(find "$ROOT/internal" "$ROOT/cmd" -name '*.templ' -print 2>/dev/null |
    while IFS= read -r src; do
      out=$(dirname "$src")/$(basename "$src" .templ)_templ.go
      if [ ! -f "$out" ]; then
        echo yes
        break
      fi
    done || true)
  [ -n "$missing" ]
}

needs_generate || exit 0
exec 9>"$LOCK"
flock -w 180 9
needs_generate || exit 0

cd "$ROOT"
templ generate
touch "$STAMP"
Enter fullscreen mode Exit fullscreen mode

The second check matters: another container may have generated the files while this process waited. Missing-output detection also recovers from a deleted *_templ.go even when no source timestamp changed.

What We Learned

Automate the generation step. Treat it like protobuf code generation: run it consistently and never hand-edit the output. Whether generated files belong in version control is a team decision; we commit them so builds do not depend on generation happening implicitly.

Templ + HTMX is a natural pair. Templ components return HTML fragments. HTMX swaps HTML fragments. No JSON serialization layer needed:

templ SearchResults(cards []CatalogCardView) {
    for _, card := range cards {
        @CatalogCard(card)
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler renders the component and returns it as an HTMX response. The browser swaps the fragment, with no client-side rendering layer.

Compile-time feedback scales well. A renamed field now fails generation or compilation instead of waiting for the affected template path to execute.


This article is based on lessons from building Towami, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.

Top comments (0)