Skip to content
gohacks.dev
Web

Templ for Type Safe HTML Components in Go Web Apps

A practical templ tutorial for Go: write components in .templ files, generate Go code, compose layouts, serve them over HTTP and test the output.

12 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the HTML5 logo.

Go's html/template has served web developers for a decade, and its weakness is the same one it started with: templates are strings parsed at runtime. A field name misspelled in {{.Titel}} compiles, ships, and fails when a visitor reaches the page. A function renamed in Go does not warn the template that calls it. Editors treat the file as plain text. templ takes the opposite approach. You write markup in .templ files with Go expressions inside, a generator turns them into ordinary Go functions, and the compiler checks everything the way it checks the rest of your program.

Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple Silicon (arm64)

This article is the entry point to the templ series. It writes a small component set, generates the code, renders it over HTTP and tests it, with every example run on Go 1.25.6 and templ v0.3.1001. The later articles go deep on each topic: the first component and the code it generates, layouts and composition, HTMX, forms, routers, live reload, testing, streaming, comparing it with html/template, two applications, component libraries, escaping and a cheat sheet. It belongs to the web cluster that ends in a production HTTP server.

How it works

You write shop.templ. The templ generate command reads it and writes shop_templ.go next to it, containing a Go function for each templ declaration. The function takes your parameters and returns a templ.Component, an interface with one method, Render(ctx context.Context, w io.Writer) error. Static markup becomes constant strings written to the writer, expressions become calls that escape their value, and if, for and switch become the Go statements they look like. There is no template parsing at runtime, no reflection and no interface{} data bag: the parameters have types.

That one fact explains most of what is attractive about it. A parameter type error is a compile error. Renaming a field in your Go struct is a compile error in every .templ file that uses it, and the compiler lists them. Go to definition and find references work across the Go and templ boundary when the language server is running. And the generated code is plain Go you can read and step through in a debugger, which the next article does.

The workflow

Install templ as a tool dependency of your module (Go 1.24 added the tool directive for exactly this), so everyone on the project runs the same version:

Shell
go get github.com/a-h/[email protected]
go get -tool github.com/a-h/templ/cmd/[email protected]
go tool templ generate

The first line adds the runtime library, the second records the generator as a tool of the module in go.mod, and the third runs it. Every example in this series was built with these three commands. The alternative is go install github.com/a-h/templ/cmd/templ@latest, which puts a binary on your path and lets versions drift between machines, so I prefer the tool directive.

Decide up front whether to commit the generated _templ.go files. For a library, commit them, since consumers do not run your generator. For an application, either works. If you do not commit them, add *_templ.go to .gitignore and run go tool templ generate as the first step in CI, in the Dockerfile and in the watch loop; the build fails loudly if you forget. If you commit them, add a CI step that regenerates and fails on a diff, so a stale file cannot slip through. Either rule beats the third option, which is arguing about it when a deploy breaks.

A small component set

The example is a shop page: a layout, a list of products with stock badges, and a link. It exercises most of the syntax you will use daily.

shop.templText
package main

import "fmt"

type Item struct {
	Name  string
	Price float64
	Stock int
}

// Layout wraps whatever it is given. children... is the slot.
templ Layout(title string) {
	<!DOCTYPE html>
	<html lang="en">
		<head>
			<meta charset="utf-8"/>
			<title>{ title }</title>
		</head>
		<body>
			<main>
				{ children... }
			</main>
		</body>
	</html>
}

// Badge takes arbitrary extra attributes and spreads them onto the element.
templ Badge(label string, attrs templ.Attributes) {
	<span class="badge" { attrs... }>{ label }</span>
}

templ ItemList(items []Item) {
	<ul>
		for i, it := range items {
			<li class={ "row", templ.KV("sold-out", it.Stock == 0) } data-index={ fmt.Sprint(i) }>
				{ it.Name } - { fmt.Sprintf("%.2f", it.Price) }
				if it.Stock == 0 {
					@Badge("sold out", templ.Attributes{"title": "none left"})
				} else if it.Stock < 3 {
					@Badge("low", nil)
				}
			</li>
		}
	</ul>
}

templ Page(items []Item) {
	@Layout("Shop") {
		<h1>Shop</h1>
		@ItemList(items)
		<a href={ templ.SafeURL("/cart?x=1") }>Cart</a>
		<p>{ "<script>alert(1)</script>" }</p>
	}
}

Take the syntax in the order it appears.

templ Name(params) declares a component, and its parameters are Go parameters with Go types. Inside, the body is HTML with Go in braces. { expr } prints a value, escaped. The expression must be a string (or return (string, error)); numbers need formatting, which is why the price goes through fmt.Sprintf. That strictness is a feature: there is no implicit conversion that could format a float differently on a Monday.

Control flow is Go. for i, it := range items, if ... else if and switch appear without template-specific syntax. Whatever you know about Go scoping and shadowing applies. There is no {{end}} to match; the braces nest as in the language.

@Component(args) calls another component, and { children... } renders whatever was passed in braces after the call. @Layout("Shop") { ... } in Page is how a layout wraps content. The layout never needs to know what is inside it. When you need more than one slot, pass components as parameters, covered in layouts and composition.

Attributes take expressions too. class={ "row", templ.KV("sold-out", it.Stock == 0) } builds a class list, adding sold-out only when the condition is true, so there is no string concatenation and no stray spaces. { attrs... } spreads a map of attributes onto an element. And a URL attribute such as href is sanitised: templ.SafeURL is the way to say "I vouch for this string"; an unvouched javascript: value would be replaced with a harmless placeholder, a behavior the escaping article tests directly.

Serving it

Rendering writes to any writer, and templ.Handler wraps a component as an http.Handler. The example mounts the page, a fragment route and a 404:

app.goGo
package main

import (
	"net/http"

	"github.com/a-h/templ"
)

var items = []Item{{"Pen", 1.5, 10}, {"Ink", 4, 2}, {"Pad", 3.25, 0}}

func NewMux() http.Handler {
	mux := http.NewServeMux()
	mux.Handle("GET /{$}", templ.Handler(Page(items)))
	mux.Handle("GET /fragment", templ.Handler(ItemList(items)))
	mux.Handle("GET /", templ.Handler(Layout("Not found"), templ.WithStatus(http.StatusNotFound)))
	return mux
}
main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
)

func main() {
	fmt.Println("rendered straight to a writer:")
	Page(items).Render(context.Background(), os.Stdout)
	fmt.Println()

	srv := httptest.NewServer(NewMux())
	defer srv.Close()
	for _, path := range []string{"/", "/fragment", "/missing"} {
		res, _ := http.Get(srv.URL + path)
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		fmt.Printf("GET %-10s %d  %s  %d bytes, has <html>: %v\n", path, res.StatusCode, res.Header.Get("Content-Type"), len(b), strings.Contains(string(b), "<html"))
	}
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
echo "generated: $(ls *_templ.go) with $(grep -c '^func ' shop_templ.go) functions"
go run .
Text
generated: shop_templ.go with 4 functions
rendered straight to a writer:
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Shop</title></head><body><main><h1>Shop</h1><ul><li class="row" data-index="0">Pen - 1.50 </li><li class="row" data-index="1">Ink - 4.00 <span class="badge">low</span></li><li class="row sold-out" data-index="2">Pad - 3.25 <span class="badge" title="none left">sold out</span></li></ul> <a href="/cart?x=1">Cart</a><p>&lt;script&gt;alert(1)&lt;/script&gt;</p></main></body></html>
GET /          200  text/html; charset=utf-8  446 bytes, has <html>: true
GET /fragment  200  text/html; charset=utf-8  242 bytes, has <html>: false
GET /missing   404  text/html; charset=utf-8  123 bytes, has <html>: true

Read the output closely. The first block is the rendered page, one line of minified HTML: templ collapses the whitespace between tags, so the output is compact and the source can be indented freely. The Pen row has no badge, Ink has the low badge because its stock is 2, and Pad has class="row sold-out" and a badge carrying the extra title attribute: the control flow and the attribute helpers did what the source says. The string containing a script tag came out as &lt;script&gt;..., escaped, because { } escapes for its context. Nobody had to remember to.

The routes follow. / is a full page, /fragment renders only the list, which is the building block for HTMX partials, and the catch-all returns the layout with a 404 status through the handler option. All three are http.Handler values, so they work in any router, as the routers article shows for chi and gin. The generator also produced exactly one file here, shop_templ.go, containing a function per component.

Testing is string comparison

Because a component renders to an io.Writer, a test renders it into a strings.Builder and checks the output, with no server and no browser.

shop_test.goGo
package main

import (
	"context"
	"strings"
	"testing"
)

func TestItemListBadges(t *testing.T) {
	var sb strings.Builder
	if err := ItemList([]Item{{"Pen", 1, 10}, {"Ink", 1, 2}, {"Pad", 1, 0}}).Render(context.Background(), &sb); err != nil {
		t.Fatal(err)
	}
	html := sb.String()
	if strings.Count(html, `class="badge"`) != 2 {
		t.Errorf("want two badges (low and sold out), got:\n%s", html)
	}
	if !strings.Contains(html, `class="row sold-out"`) {
		t.Errorf("the item with no stock should carry the sold-out class:\n%s", html)
	}
}

func TestMarkupInDataIsEscaped(t *testing.T) {
	var sb strings.Builder
	ItemList([]Item{{"<img src=x onerror=alert(1)>", 1, 5}}).Render(context.Background(), &sb)
	if strings.Contains(sb.String(), "<img") {
		t.Fatalf("an item name became markup: %s", sb.String())
	}
}
Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go test -race -count=1 -v ./...
Text
=== RUN   TestItemListBadges
--- PASS: TestItemListBadges (0.00s)
=== RUN   TestMarkupInDataIsEscaped
--- PASS: TestMarkupInDataIsEscaped (0.00s)
PASS
ok  	example.test/lab	1.254s

Two focused assertions are worth more than a snapshot of the whole page here: the list shows exactly two badges for three items, and a hostile product name never becomes an element. For larger components, golden files compare entire renders.

Reading the generated function

You rarely need to open shop_templ.go, but knowing its shape removes the mystery. For each templ declaration the generator wrote one Go function with the same name and parameters, returning templ.Component. The output of the build step above counted four of them (Layout, Badge, ItemList and Page). Inside, each function is a closure that receives a writer and a context, checks the context for cancellation, writes static strings in chunks, and calls helpers to escape dynamic values. { children... } becomes a call to render whatever component was attached to the context. Because it is ordinary Go, a stack trace from a failed render points into your .templ file thanks to line comments the generator emits, and you can set a breakpoint in the generated function. The next article walks through one in detail, including the error position information.

Passing data: keep the parameters small

A habit that pays back quickly is to give each component only what it needs. ItemList(items []Item) takes a slice, not a whole page model, so it renders from a test with three lines of setup, and the same component serves the full page and the HTMX fragment. When a component starts needing six parameters, group the ones that belong together in a struct (ItemListProps), as you would with any Go function, and resist passing *http.Request into components. A component that reads the request is a component that cannot be tested without one. Do the reading in the handler, where the request is available, and pass plain values down. The request-scoped data that really is ambient, such as the signed-in user or a CSP nonce, travels in the context, and templ has helpers for the nonce (templ.WithNonce, templ.GetNonce) so inline scripts can satisfy a strict policy.

What to expect in daily use

The edit loop has one extra step. Change a .templ file, regenerate, rebuild. The templ generate --watch command and an editor plugin make it invisible, and the live reload article sets it up. Generation takes milliseconds for a small project, and compile errors point at the .templ line, because the generated code carries source positions.

Error messages in rendering are better than with string templates for the same reason. A failure inside a component returns a templ.Error with the file, line and column of the expression that failed. Parameters are the only inputs, so there is no "missing key" class of bug; a missing field is a build failure.

The costs are real. You depend on a generator and its version, your editor needs the language server for full comfort, and contributors who know only html/template have a syntax to learn (it takes an afternoon). Streaming, partial rendering and fragments exist but are explicit rather than free; see streaming. And components compile into your binary, so there is no editing a template on a live server, which for most Go services is a feature.

Adopting it in an existing project

You do not have to rewrite a working html/template site to start. The library bridges the two in both directions: templ.FromGoHTML(tmpl, data) wraps a parsed *template.Template as a templ.Component, so a new templ page can include an old template as a child, and templ.ToGoHTML(ctx, component) renders a component to a template.HTML value you can pass into an old template. That makes an incremental migration practical. Start with the pieces that change most, such as a new admin screen or an HTMX fragment, write them as components, and leave the stable pages alone. Move the shared layout last, because everything depends on it. During the transition both renderers escape by context, so mixing them does not weaken escaping; just remember that template.HTML is a promise that a string is safe, and make it once, at the bridge.

Plan the build change at the same time. Add the generate step to the Makefile or task runner, to CI before go vet and go test, and to the Dockerfile before go build. Teams that skip this meet the one failure mode templ has: a binary built from stale generated code, which compiles fine and renders yesterday's markup. A CI check that runs the generator and fails if git diff is not empty (when you commit generated files) or builds from a clean tree (when you do not) removes it.

Where to go next

If you want one next step, read the first component article to see exactly what the generator emits, then build something small that you would otherwise write with html/template, such as a settings page with a form (forms and validation). Components pay off once there are a dozen of them, and the quickest route there is to convert a page you already know well. When the set of components starts to repeat styling decisions, move them into a component library with a documented API of its own, and test it as you would any package.

When to choose it

Pick templ when the application renders a lot of HTML on the server, especially with HTMX, when a team shares the code and values refactoring safety, or when you are building a component library. Stay with html/template when a project is small, when designers who do not use Go edit the templates, or when you want zero build steps; the comparison article measures both on the same page. For where this sits among Go's web tooling, see html/template and server rendered websites. The project documentation is at templ.guide, and the generated API is on pkg.go.dev.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.