Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 096 / 100

Go templates for emails and HTML: lessons from production

Server-side rendering with the standard library.

Expona Admin7 min read

The case for server-side template rendering

Most of us have shipped broken emails or malformed HTML to production and spent hours debugging escaping issues, template parsing failures, or security holes. Go templates for emails and HTML: lessons from production come from teams that have standardized on Go's template engines across e-commerce checkouts, transactional emails, and user-facing dashboards. Server-side rendering with the standard library keeps your rendering logic close to your data, avoids a JavaScript runtime in the critical path, and eliminates a whole class of injection vulnerabilities when done correctly.

We've learned that the difference between a fragile template system and a bulletproof one comes down to three decisions: which template engine you pick, when and how you parse, and how you structure your template files. Let's walk through the patterns that work.

html/template escapes output by default

The html/template package is your default choice for any HTML output. Unlike text/template, it automatically escapes content to prevent XSS attacks. When you render user data into an HTML context—attribute values, text nodes, CSS, JavaScript—html/template understands the context and applies the right escaping rules.

Consider a user profile page where you display a user's bio:

go
package main

import (
	"html/template"
	"os"
)

func main() {
	tmpl, err := template.New("profile").Parse(`
<div class="bio">
  <p>{{.Bio}}</p>
  <a href="{{.Website}}">Visit site</a>
</div>
	`)
	if err != nil {
		panic(err)
	}

	data := map[string]string{
		"Bio":     "<script>alert('xss')</script>",
		"Website": "javascript:alert('xss')",
	}

	tmpl.Execute(os.Stdout, data)
}

The output is safe:

go
<div class="bio">
  <p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>
  <a href="#ZgotmplZ">Visit site</a>
</div>

The <script> tag is HTML-escaped. The JavaScript URL is neutered to #ZgotmplZ. This happens automatically—you didn't have to call an escape function. html/template understands that the first {{.Bio}} is in a text context and the second {{.Website}} is in an href attribute.

The catch: if you need to include raw HTML—a blog post body, a rich email signature, pre-rendered content—you must explicitly mark it safe using the template.HTML type. This forces you to think about where trusted content comes from.

go
data := map[string]interface{}{
	"Body": template.HTML("<strong>Welcome</strong>"),
}

tmpl, _ := template.New("post").Parse("<article>{{.Body}}</article>")
tmpl.Execute(os.Stdout, data)

Output: <article><strong>Welcome</strong></article>. The content is not escaped because you explicitly declared it safe. Use this sparingly and only for content you control.

text/template for plain text emails and logs

Not every template produces HTML. Transactional emails, plain-text reports, and log messages need text/template, which does no escaping. Use it when you're generating text where HTML escaping would break the output.

A common case: order confirmation emails sent as plain text and HTML both.

go
package main

import (
	"text/template"
	"os"
)

func main() {
	tmpl, err := template.New("email").Parse(`
Order Confirmation
==================

Order ID: {{.OrderID}}
Customer: {{.CustomerName}}

Items:
{{range .Items}}
  - {{.Name}}: ${{.Price}}
{{end}}

Total: ${{.Total}}

---
Reply to this email if you have questions.
	`)
	if err != nil {
		panic(err)
	}

	data := map[string]interface{}{
		"OrderID":      "ORD-2024-001",
		"CustomerName": "Alice Johnson",
		"Items": []map[string]string{
			{"Name": "Widget", "Price": "19.99"},
			{"Name": "Gadget", "Price": "29.99"},
		},
		"Total": "49.98",
	}

	tmpl.Execute(os.Stdout, data)
}

The output is clean, plain text—no HTML entities, no escaping surprises.

One advantage of text/template: it's lighter weight and doesn't maintain a context state machine. If you're rendering thousands of small templates (like log lines or CSV rows), text/template is faster. For emails, you'll usually pair it with an HTML version rendered from html/template, so recipients using rich email clients get proper formatting.

Embed templates with go:embed

Distributing template files alongside your binary is a solved problem with Go 1.16's //go:embed directive. Load templates at build time, embed them in the binary, and never worry about missing files in production.

go
package main

import (
	"embed"
	"html/template"
	"os"
)

//go:embed templates/*.html
var templateFS embed.FS

func main() {
	tmpl, err := template.ParseFS(templateFS, "templates/*.html")
	if err != nil {
		panic(err)
	}

	data := map[string]string{"Title": "Dashboard"}
	tmpl.ExecuteTemplate(os.Stdout, "index.html", data)
}

Your directory structure:

go
project/
  main.go
  templates/
    index.html
    header.html
    footer.html

At build time, go build embeds all matching templates into the binary. No separate file serving, no path traversal bugs, and the bundle is immutable once deployed.

For larger applications, organize templates by feature:

go
//go:embed templates/emails/*.html templates/pages/*.html
var templateFS embed.FS

Or define separate embed variables for different template sets if they're used independently:

go
//go:embed templates/emails/*.html
var emailTemplates embed.FS

//go:embed templates/pages/*.html
var pageTemplates embed.FS

This keeps templates close to the code that uses them and guarantees consistency between code and templates at deployment time.

Parse once at startup

The most common performance leak in production is parsing templates on every request. Parsing is CPU-intensive—Go has to tokenize, compile, and cache the template AST. If you parse in your handler, you're doing this work for every single request.

Parse all templates once during application startup and reuse them:

go
package main

import (
	"embed"
	"html/template"
	"net/http"
)

//go:embed templates/*.html
var templateFS embed.FS

var templates *template.Template

func init() {
	var err error
	templates, err = template.ParseFS(templateFS, "templates/*.html")
	if err != nil {
		panic(err)
	}
}

func handleProfile(w http.ResponseWriter, r *http.Request) {
	data := map[string]string{
		"Username": "alice",
		"Bio":      "Engineer",
	}
	templates.ExecuteTemplate(w, "profile.html", data)
}

func main() {
	http.HandleFunc("/profile", handleProfile)
	http.ListenAndServe(":8080", nil)
}

The init() function runs before main(), parsing all templates once. Each request calls ExecuteTemplate, which renders the already-compiled template. Rendering is fast—typically microseconds, not milliseconds.

For larger teams, wrap templates in a struct with helper methods:

go
type TemplateSet struct {
	tmpl *template.Template
}

func NewTemplateSet() (*TemplateSet, error) {
	tmpl, err := template.ParseFS(templateFS, "templates/*.html")
	if err != nil {
		return nil, err
	}
	return &TemplateSet{tmpl: tmpl}, nil
}

func (ts *TemplateSet) RenderProfile(w http.ResponseWriter, username, bio string) error {
	return ts.tmpl.ExecuteTemplate(w, "profile.html", map[string]string{
		"Username": username,
		"Bio":      bio,
	})
}

This pattern makes it easy to add template helpers (markdown rendering, date formatting, custom functions) and keeps rendering logic organized in one place.

Common mistakes

Parsing in handlers: Every time you call template.ParseFS or template.New().Parse() in a request handler, you waste CPU and latency. Parse once, reuse always.

Forgetting context in html/template: If you use {{.JavaScript}} in a <script> tag, html/template escapes it for HTML context, not JavaScript context. The result is broken JavaScript. Use explicit escaping or generate JavaScript from your handler instead.

go
// Wrong
<script>
  var data = {{.JSONData}};
</script>

// Right: encode to JSON in the handler
<script>
  var data = {{.JSONData | js}};
</script>

Mixing user input into template names: Never use user-provided input to select which template to execute. Load template names from a whitelist or a config file.

go
// Wrong
tmpl.ExecuteTemplate(w, r.Query.Get("template"), data)

// Right
validTemplates := map[string]bool{"profile": true, "dashboard": true}
name := r.Query.Get("template")
if !validTemplates[name] {
	http.Error(w, "not found", 404)
	return
}
tmpl.ExecuteTemplate(w, name, data)

Ignoring Execute errors: Always check the error return from Execute and ExecuteTemplate. Template rendering can fail if your data doesn't match the template structure or if a custom function panics.

Not testing email and HTML output: Template bugs are easy to miss in code review. Render templates in your tests, compare the output, and catch escaping issues early.

Key takeaways

  • Use html/template for HTML output and text/template for plain text. html/template escapes by default and prevents XSS when used correctly.
  • Embed templates with //go:embed and template.ParseFS to bundle them immutably with your binary.
  • Parse all templates once during application startup, never on every request. Store the compiled template set and reuse it.
  • Be explicit about safe HTML with template.HTML and understand context-aware escaping in html/template.
  • Test template output in unit tests to catch escaping and formatting bugs before production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles