How to Use templ for Type-Safe Go Templates

Web
Generate type-safe Go code from templ files using the templ generate command and import the resulting package.

When template errors crash your app at runtime

You spend an afternoon building a dashboard in Go. You write the backend logic, test the database queries, and finally drop in some HTML templates. You rename a struct field from UserName to Username. You run the server. You hit the page. The browser shows a blank spot or a cryptic 500 Internal Server Error. You dig through logs and find a template parsing panic about an undefined variable. The code compiled perfectly. The error only appeared when a real request hit the route.

This is the classic pain point of Go's built-in html/template package. Templates are parsed at runtime. Type mismatches, missing variables, and syntax typos survive go build and only surface when the template engine tries to execute them. Debugging feels like playing whack-a-mole with string interpolation.

The templ library removes that gap. Instead of parsing HTML at runtime, it compiles your templates into actual Go code during your build step. Every variable, every conditional, and every loop becomes a standard Go function call. The compiler checks types before the server ever starts. If a field name changes, the build fails immediately. You get IDE autocomplete, static analysis, and the same guarantees you expect from the rest of your codebase.

How templ turns HTML into Go functions

templ is a compiler disguised as a templating library. You write files with a .templ extension. The syntax looks like HTML, but it accepts Go expressions inside curly braces. You run the templ CLI, and it generates .go files containing functions that return HTML. Those functions write directly to an io.Writer, which means they work with http.ResponseWriter, bytes.Buffer, or any standard Go sink.

Think of it like a blueprint that gets stamped by an engineer before construction begins. Traditional templates are like handing the blueprint to the builders and hoping they read it correctly on site. templ forces the engineer to verify every measurement, every material specification, and every load-bearing wall before a single brick is laid. The runtime never sees raw template strings. It only sees compiled Go functions.

The trade-off is a build step. You cannot skip templ generate. The generated code lives alongside your source files, usually in a templ subdirectory or inline depending on your configuration. Most developers run it via make targets, air during development, or pre-build hooks in CI. The overhead is negligible once the workflow is set up, and the payoff is catching template bugs at compile time instead of in production.

The minimal setup

Here is the simplest possible workflow. You create a template file, run the generator, and call the resulting function from main.go.

// main.go
package main

import (
	"fmt"
	"your/module/templates"
)

func main() {
	// Call the generated function. It returns a templ.Component.
	// The Render method writes HTML to the provided writer.
	component := templates.HelloWorld()
	component.Render(nil, fmt.Println)
}

The corresponding template file lives at templates/hello.templ:

package templates

// HelloWorld returns a component that renders a greeting.
templ HelloWorld() {
	// The <h1> tag is static HTML.
	// templ compiles this directly to a writer.Write call.
	<h1>Hello, world!</h1>
}

Run the generator in your terminal:

# Generates .go files from all .templ files in the current directory tree
templ generate

After generation, go run . prints <h1>Hello, world!</h1> to stdout. The template never parses a string at runtime. The HelloWorld function is a real Go function that constructs HTML by writing bytes to a buffer.

What happens under the hood

When you run templ generate, the CLI parses every .templ file and builds an abstract syntax tree. It resolves Go expressions, validates tag nesting, and checks type signatures. Then it emits Go source code. A simple tag like <p>{name}</p> becomes a sequence of writer.Write calls. The first call writes <p>, the second writes the string representation of name, and the third writes </p>.

This compilation step eliminates the template parsing phase entirely. Standard html/template must read the string, tokenize it, build a tree, and execute it on every render. templ does all of that once, at build time. The runtime cost is just function calls and byte writes. Benchmarks consistently show templ rendering faster than html/template for equivalent output, especially when templates contain loops or conditionals.

Type safety flows through the entire pipeline. If you pass a string where a User struct is expected, the Go compiler rejects it. If you reference a variable that does not exist in scope, the compiler rejects it. If you forget to close a tag, the templ parser catches it during generation. You get the same feedback loop you use for business logic.

Convention aside: templ respects gofmt rules for the generated Go code. You do not need to argue about indentation or brace placement. The generator produces idiomatic Go that passes go vet and staticcheck. Run templ fmt on your .templ files to keep the DSL itself consistent across your team.

A realistic component with props

Real applications pass data into templates. templ handles this through function parameters. You define a component, give it typed arguments, and use those arguments inside the template body. The compiler enforces that every call site provides the correct types.

// handlers.go
package main

import (
	"net/http"
	"your/module/templates"
)

// User represents a basic profile object.
type User struct {
	Name string
	Role string
}

// HandleProfile writes a user profile page to the response.
func HandleProfile(w http.ResponseWriter, r *http.Request) {
	// Simulate fetching a user from a database.
	user := User{Name: "Alex", Role: "admin"}

	// Pass the user struct into the component.
	// The compiler verifies that User matches the component signature.
	component := templates.ProfileCard(user)

	// Set the content type before rendering.
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	component.Render(nil, w)
}

The template file accepts the User struct and renders it conditionally:

package templates

// ProfileCard renders a user card with role-based styling.
templ ProfileCard(user User) {
	// The <div> wraps the entire component.
	// templ automatically escapes string values to prevent XSS.
	<div class="card">
		<h2>{user.Name}</h2>
		// Conditional rendering uses standard Go syntax.
		// The compiler checks that user.Role is a string.
		@if user.Role == "admin" {
			<span class="badge admin">Administrator</span>
		} else {
			<span class="badge user">Member</span>
		}
	</div>
}

Notice how the template uses standard Go control flow. @if, @for, and @switch map directly to Go statements. You can import packages, call functions, and use type assertions inside the template body. The generated code preserves these constructs as native Go, which means your linters and profilers work exactly as they do elsewhere.

Convention aside: context.Context always travels as the first parameter in long-lived functions. If your component needs to cancel a database query or respect a timeout, pass ctx context.Context as the first argument to the templ function. The generated code will forward it through any nested calls, keeping cancellation boundaries explicit.

Common traps and compiler feedback

templ shifts errors from runtime to compile time, but new patterns introduce new failure modes. Understanding how the compiler reports them saves hours of debugging.

Forgetting to run templ generate is the most common mistake. If you edit a .templ file and run go build without regenerating, the compiler cannot find the generated functions. You get undefined: templates.ProfileCard or imported and not used if the package exists but the function signature changed. The fix is always the same: run templ generate before building. Most teams automate this with air --build.cmd "templ generate && go build" during development.

Type mismatches surface immediately. If you change User.Role from string to int, every template that references user.Role breaks. The compiler rejects the program with cannot use user.Role (untyped int constant) as string value in argument or a similar type mismatch error. This is intentional. The compiler forces you to update the template alongside the struct.

Closing tags matters. templ parses the DSL strictly. If you write <div class="card"> without a matching </div>, the generator stops and prints a syntax error pointing to the exact line. You cannot accidentally leak HTML tags into production.

Raw HTML requires explicit opt-in. templ escapes all interpolated values by default. If you need to render unescaped HTML from a rich text editor, you must use the @raw directive or the templ.Raw() helper. The compiler will warn you if you try to pass a string to a function expecting templ.Component. This design prevents accidental XSS vulnerabilities while still allowing controlled rendering when necessary.

Convention aside: error handling follows the standard if err != nil { return err } pattern. templ components do not return errors by default. If your rendering logic can fail, wrap the component in a function that returns (templ.Component, error). Check the error before calling .Render(). The boilerplate is verbose by design, and the community accepts it because it makes failure paths visible.

The worst template bug is the one that silently renders empty HTML. templ eliminates that category entirely. If the data is missing, the build fails. If the type is wrong, the build fails. If the tag is unclosed, the build fails. Trust the compiler. Argue logic, not formatting.

When to reach for templ

Choosing a templating strategy depends on your project size, team experience, and performance requirements. templ fits specific workflows better than others.

Use templ when you want compile-time guarantees for HTML generation and are willing to add a build step. Use html/template when you need zero dependencies, want to parse templates from a database at runtime, or are maintaining a legacy codebase that already relies on Go's standard library. Use plain string concatenation or fmt.Sprintf when you are generating non-HTML output like CSV, JSON, or CLI prompts. Use a full-stack framework like Echo or Gin with their built-in template engines only when you prioritize rapid prototyping over type safety and are comfortable debugging runtime template panics. Use templ when your team values IDE autocomplete, static analysis, and consistent formatting across frontend and backend code.

Where to go next