Go Template Rendering: Server-Side HTML

Server-side rendering (SSR) delivers fully-formed HTML to the browser, eliminating the JavaScript-heavy initialization dance that plagues single-page applications. Go's template packages excel at...

Key Insights

  • Go’s html/template package provides automatic XSS protection through context-aware escaping, making it significantly safer than string concatenation or text templates for HTML generation
  • Template composition with block and template actions enables DRY layouts where you define base structures once and override specific sections per page
  • Parsing templates once at startup and caching them dramatically improves performance compared to parsing on every request—use template.Must() for fail-fast initialization

Why Server-Side Rendering with Go

Server-side rendering (SSR) delivers fully-formed HTML to the browser, eliminating the JavaScript-heavy initialization dance that plagues single-page applications. Go’s template packages excel at this: they’re fast, memory-efficient, and ship with the standard library.

Use Go templates when you need:

  • Fast initial page loads with minimal client-side JavaScript
  • SEO-friendly content that’s immediately indexable
  • Simple, progressive enhancement workflows
  • Lower client-side complexity and battery drain

The html/template package is your default choice for web applications. It’s identical to text/template in syntax but adds automatic escaping to prevent XSS attacks. Never use text/template for HTML—you’ll regret it when user input breaks your pages or injects malicious scripts.

package main

import (
    "html/template"
    "os"
)

func main() {
    tmpl := template.Must(template.New("hello").Parse("<h1>Hello, {{.Name}}!</h1>"))
    data := struct{ Name string }{Name: "World"}
    tmpl.Execute(os.Stdout, data)
}

Template Syntax Fundamentals

Templates use {{ }} delimiters for actions. The dot . represents the current data context—initially the data you pass to Execute(), but it changes inside control structures.

Variables start with $ and persist across their scope. Pipelines pass data through functions using the | operator, similar to Unix pipes.

{{- $user := .CurrentUser -}}
<div class="profile">
    {{if $user.IsAdmin}}
        <span class="badge">Admin</span>
    {{else if $user.IsModerator}}
        <span class="badge">Moderator</span>
    {{else}}
        <span class="badge">User</span>
    {{end}}
    
    <h2>{{$user.Name}}</h2>
    
    {{range $user.Posts}}
        <article>
            <h3>{{.Title}}</h3>
            <p>{{.Excerpt}}</p>
        </article>
    {{else}}
        <p>No posts yet.</p>
    {{end}}
</div>

The {{- and -}} syntax trims whitespace before and after actions, keeping your HTML clean. The range action iterates over slices, arrays, and maps, changing the dot context to each element. The else clause handles empty collections.

Passing Data to Templates

Structure your data with Go structs. Templates access exported fields (capitalized) using dot notation. This approach provides type safety and clarity.

type PageData struct {
    Title       string
    CurrentUser *User
    Posts       []Post
    Meta        map[string]string
}

type User struct {
    ID       int
    Name     string
    IsAdmin  bool
    Posts    []Post
}

type Post struct {
    ID      int
    Title   string
    Excerpt string
    Author  *User
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    data := PageData{
        Title: "My Blog",
        CurrentUser: &User{
            ID:      1,
            Name:    "Alice",
            IsAdmin: true,
        },
        Posts: []Post{
            {ID: 1, Title: "First Post", Excerpt: "Getting started..."},
            {ID: 2, Title: "Second Post", Excerpt: "Going deeper..."},
        },
        Meta: map[string]string{
            "description": "A technical blog",
            "keywords":    "go,templates,web",
        },
    }
    
    tmpl.Execute(w, data)
}

Nested data works naturally. Access {{.CurrentUser.Name}} or iterate with {{range .Posts}}{{.Title}}{{end}}. Maps use the same syntax: {{.Meta.description}}.

Template Composition and Reusability

Real applications need layouts. Define a base template with block actions, then override those blocks in child templates.

templates/base.html:

{{define "base"}}
<!DOCTYPE html>
<html>
<head>
    <title>{{block "title" .}}Default Title{{end}}</title>
</head>
<body>
    <nav>{{template "nav" .}}</nav>
    <main>
        {{block "content" .}}
            <p>No content defined.</p>
        {{end}}
    </main>
    <footer>{{template "footer" .}}</footer>
</body>
</html>
{{end}}

{{define "nav"}}
<ul>
    <li><a href="/">Home</a></li>
    <li><a href="/about">About</a></li>
</ul>
{{end}}

{{define "footer"}}
<p>&copy; 2024 My Site</p>
{{end}}

templates/index.html:

{{template "base" .}}

{{define "title"}}Home - My Site{{end}}

{{define "content"}}
<h1>Welcome</h1>
{{range .Posts}}
    <article>
        <h2>{{.Title}}</h2>
        <p>{{.Excerpt}}</p>
    </article>
{{end}}
{{end}}

Parse multiple files together:

var tmpl *template.Template

func init() {
    tmpl = template.Must(template.ParseGlob("templates/*.html"))
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    data := getPageData()
    tmpl.ExecuteTemplate(w, "base", data)
}

The block action defines a default implementation that child templates can override. The template action includes another template by name, passing the current data context.

Custom Functions and Pipelines

Built-in functions like len, index, and printf cover basics, but custom functions unlock real power.

func init() {
    funcMap := template.FuncMap{
        "formatDate": func(t time.Time) string {
            return t.Format("Jan 2, 2006")
        },
        "truncate": func(s string, length int) string {
            if len(s) <= length {
                return s
            }
            return s[:length] + "..."
        },
        "add": func(a, b int) int {
            return a + b
        },
    }
    
    tmpl = template.Must(
        template.New("").Funcs(funcMap).ParseGlob("templates/*.html"),
    )
}

Use functions in templates:

<time>{{.PublishedAt | formatDate}}</time>
<p>{{.Content | truncate 150}}</p>
<span>Page {{add .CurrentPage 1}} of {{.TotalPages}}</span>

Pipelines chain operations left-to-right. The result of each function becomes the last argument to the next. This is powerful for transforming data inline without cluttering your Go handlers.

Security and Best Practices

The html/template package automatically escapes content based on context. HTML entities, JavaScript strings, CSS values, and URLs each get appropriate escaping.

type Comment struct {
    Author  string
    Content string
}

// In template:
// <div class="comment">
//     <strong>{{.Author}}</strong>
//     <p>{{.Content}}</p>
// </div>

If a user submits <script>alert('XSS')</script> as content, it renders as escaped text: &lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;. The browser displays it harmlessly.

Context-aware escaping handles JavaScript too:

<script>
var username = "{{.Username}}";
var posts = {{.PostsJSON}};
</script>

This correctly escapes quotes in Username and expects PostsJSON to be pre-marshaled JSON. For JSON, use a custom function:

"toJSON": func(v interface{}) template.JS {
    b, _ := json.Marshal(v)
    return template.JS(b)
}

Performance: Parse templates once at startup, not per request. Use template.Must() to panic on parse errors during initialization—fail fast rather than serving broken pages.

var tmpl *template.Template

func init() {
    tmpl = template.Must(template.ParseGlob("templates/*.html"))
}

For development, re-parse on each request to see changes without restarting. Use build tags to separate dev and production behavior.

Real-World Example: Blog Post Renderer

Here’s a complete blog server with routing, error handling, and template rendering:

package main

import (
    "html/template"
    "log"
    "net/http"
    "time"
)

type Post struct {
    ID          int
    Title       string
    Content     string
    PublishedAt time.Time
}

type PageData struct {
    Title string
    Posts []Post
}

var tmpl *template.Template

func init() {
    funcMap := template.FuncMap{
        "formatDate": func(t time.Time) string {
            return t.Format("January 2, 2006")
        },
    }
    
    tmpl = template.Must(
        template.New("").Funcs(funcMap).ParseGlob("templates/*.html"),
    )
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
    posts := []Post{
        {
            ID:          1,
            Title:       "Go Templates are Powerful",
            Content:     "Server-side rendering with Go is fast and secure...",
            PublishedAt: time.Now().AddDate(0, 0, -2),
        },
        {
            ID:          2,
            Title:       "Building Web Apps in Go",
            Content:     "The standard library provides everything you need...",
            PublishedAt: time.Now().AddDate(0, 0, -1),
        },
    }
    
    data := PageData{
        Title: "My Blog",
        Posts: posts,
    }
    
    if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
        log.Printf("Template error: %v", err)
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }
}

func main() {
    http.HandleFunc("/", handleIndex)
    
    log.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

This pattern scales. Add more handlers, extract data fetching to a repository layer, and expand your templates. The fundamentals remain: parse once, execute per request, handle errors gracefully.

Go templates aren’t flashy, but they’re reliable, fast, and secure by default. For content-focused sites, admin panels, and server-rendered applications, they’re an excellent choice that keeps your stack simple and your users happy with fast page loads.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.