11 - Templates with html/template

📋 Jump to Takeaways

Your API returns JSON. That's great for frontends and mobile apps. But sometimes you need to render HTML. A simple admin page, a status dashboard, a bookmarks list you can actually look at in a browser. Go's html/template package handles this. It's not React. It's not even close. But for server-rendered pages, it does the job with zero dependencies.

The Basics

A template is a string with placeholders. You parse it, execute it with data, and get HTML back:

package main

import (
    "html/template"
    "os"
)

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

template.New("greeting") creates a template with the name "greeting". The name identifies the template when you have multiple templates — you'll use it later with {{template "name"}} to call one template from another. For a single template it doesn't matter much, but pick something descriptive. Avoid throwaway names like "hello" or "temp".

{{.Name}} accesses the Name field on whatever data you pass in. The dot (.) is the current context. template.Must panics if parsing fails. Use it for templates you parse at startup. If they're broken, you want to know immediately.

Passing Data

Templates work with any Go type. Structs, maps, slices. The dot adapts:

type PageData struct {
    Title     string
    Bookmarks []Bookmark
    Count     int
}

tmpl.Execute(w, PageData{
    Title:     "My Bookmarks",
    Bookmarks: bookmarks,
    Count:     len(bookmarks),
})

Inside the template, {{.Title}} gets the string, {{.Count}} gets the int. For slices, you use range:

<ul>
{{range .Bookmarks}}
    <li><a href="{{.URL}}">{{.Title}}</a></li>
{{end}}
</ul>

Inside the range block, the dot shifts to the current element. So {{.URL}} is the bookmark's URL, not the page data's URL.

Conditionals

{{if .Bookmarks}}
    <p>You have {{.Count}} bookmarks.</p>
{{else}}
    <p>No bookmarks yet.</p>
{{end}}

You can chain {{else if}} for simple conditions:

{{if .IsAdmin}}
    <span>Admin</span>
{{else if .IsEditor}}
    <span>Editor</span>
{{else}}
    <span>Viewer</span>
{{end}}

But there are no boolean operators like && or ||. You do have comparison functions — eq, ne, lt, gt, le, ge — but they're called as functions, not operators:

{{if eq .Role "admin"}}Admin{{end}}
{{if gt .Count 0}}Has items{{end}}

For anything more complex, do the logic in Go and pass the result to the template.

Template Functions

You can register custom functions before parsing:

funcMap := template.FuncMap{
    "upper": strings.ToUpper,
    "formatTime": func(t time.Time) string {
        return t.Format("Jan 02, 2006")
    },
}

tmpl := template.Must(
    template.New("page").Funcs(funcMap).Parse(pageHTML),
)

Then use them in templates:

<span>{{.CreatedAt | formatTime}}</span>
<span>{{.Title | upper}}</span>

The pipe (|) passes the left side as the last argument to the function on the right. Same idea as Unix pipes.

Layouts with Nested Templates

Real pages share a layout: header, footer, nav. You don't want to repeat that in every template. Use define, block, and template to compose them.

Here's a minimal example to see how they work together:

package main

import (
    "html/template"
    "os"
)

func main() {
    const layout = `Header
{{template "body" .}}
Footer`

    const page = `{{define "body"}}Hello, {{.Name}}!{{end}}`

    tmpl := template.Must(template.New("layout").Parse(layout))
    template.Must(tmpl.Parse(page))

    tmpl.Execute(os.Stdout, struct{ Name string }{"World"})
}
// Output:
// Header
// Hello, World!
// Footer

The layout calls {{template "body" .}} — a slot. The page fills it with {{define "body"}}...{{end}}. When you execute the layout, it pulls in the defined block. The . passes data through so the nested template can use {{.Name}}.

{{define "body"}}...{{end}} creates a named template inline — it's the same as putting that content in a separate file. These are equivalent:

// Inline: define inside a parsed string
tmpl.Parse(`{{define "body"}}Hello, {{.Name}}!{{end}}`)

// File: same define, but in body.html
tmpl.ParseFiles("body.html")

Either way, you get a template named "body" that the layout can call. In the real example below, each page is its own file — but the mechanism is the same define/template pair.

Define a base layout. The {{template "name" .}} calls insert named templates — think of them as slots that pages fill in. The . passes the current data so the nested template can access it.

{{/* templates/layout.html */}}
<!DOCTYPE html>
<html>
<head><title>{{template "title" .}}</title></head>
<body>
    <nav><a href="/">Bookmarks</a></nav>
    <main>{{template "content" .}}</main>
    <footer>Bookmarks API</footer>
</body>
</html>

Define a page that fills in those slots with {{define "name"}}...{{end}}:

{{/* templates/list.html */}}
{{define "title"}}My Bookmarks{{end}}

{{define "content"}}
<h1>Bookmarks ({{.Count}})</h1>
<ul>
{{range .Bookmarks}}
    <li>
        <a href="{{.URL}}">{{.Title}}</a>
        <small>{{.CreatedAt | formatTime}}</small>
    </li>
{{else}}
    <li>No bookmarks yet.</li>
{{end}}
</ul>
{{end}}

{{define "title"}}My Bookmarks{{end}} declares a named block called "title" with the content My Bookmarks. It doesn't render anything on its own — it only renders when the layout calls {{template "title" .}}. Think of define as "here's what this slot should contain" and template as "insert that slot here."

Parse them together:

tmpl := template.Must(
    template.New("layout.html").Funcs(funcMap).ParseFiles(
        "templates/layout.html",
        "templates/list.html",
    ),
)

Execute the layout, and it pulls in the named blocks from the page template. The first file's name becomes the template name you execute.

Rendering in a Handler

Wire it into an HTTP handler:

func handleBookmarksList(tmpl *template.Template, store *BookmarkStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        bookmarks, err := store.List(r.Context())
        if err != nil {
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }

        data := PageData{
            Title:     "Bookmarks",
            Bookmarks: bookmarks,
            Count:     len(bookmarks),
        }

        w.Header().Set("Content-Type", "text/html; charset=utf-8")
        if err := tmpl.Execute(w, data); err != nil {
            slog.Error("template execute", "error", err)
        }
    }
}

Register it on the mux:

tmpl := template.Must(
    template.New("layout.html").Funcs(funcMap).ParseFiles(
        "templates/layout.html",
        "templates/list.html",
    ),
)

mux.HandleFunc("GET /", handleBookmarksList(tmpl, store))

Parse templates once at startup. Don't parse on every request. Parsing is expensive. Execution is cheap.

Form Handling

A page that only displays data isn't very useful. Let's add a form so users can create bookmarks from the browser. HTML forms submit data as POST requests with form-encoded bodies. Go handles this with r.FormValue.

Add a form to the template:

{{define "content"}}
<h1>Bookmarks ({{.Count}})</h1>

<form method="POST" action="/bookmarks">
    <input type="text" name="title" placeholder="Title" required>
    <input type="url" name="url" placeholder="https://example.com" required>
    <button type="submit">Add</button>
</form>

<ul>
{{range .Bookmarks}}
    <li>
        <a href="{{.URL}}">{{.Title}}</a>
        <small>{{.CreatedAt | formatTime}}</small>
        <form method="POST" action="/bookmarks/{{.ID}}/delete">
            <button type="submit">✕</button>
        </form>
    </li>
{{else}}
    <li>No bookmarks yet.</li>
{{end}}
</ul>
{{end}}

Two forms here. The top one creates a bookmark. Each list item has a tiny form that deletes it. HTML forms only support GET and POST — no DELETE method. So we use POST /bookmarks/{id}/delete instead of DELETE /bookmarks/{id}. The API still has the proper REST endpoints; these HTML routes are separate.

Now the handlers. Creating a bookmark from a form:

func handleBookmarksCreate(store *BookmarkStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        title := r.FormValue("title")
        url := r.FormValue("url")
        if title == "" || url == "" {
            http.Error(w, "title and url are required", http.StatusBadRequest)
            return
        }
        if _, err := store.Create(r.Context(), url, title); err != nil {
            slog.Error("create bookmark", "err", err)
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/", http.StatusSeeOther)
    }
}

r.FormValue("title") reads the title field from the form body. After creating the bookmark, we redirect back to / with http.StatusSeeOther (303). This is the Post/Redirect/Get pattern — it prevents duplicate submissions when the user refreshes the page. Without the redirect, refreshing would re-submit the form and create another bookmark.

Deleting works the same way:

func handleBookmarksDelete(store *BookmarkStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        id, err := strconv.Atoi(r.PathValue("id"))
        if err != nil {
            http.Error(w, "invalid id", http.StatusBadRequest)
            return
        }
        if err := store.Delete(r.Context(), id); err != nil {
            slog.Error("delete bookmark", "err", err)
            http.Error(w, "internal error", http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/", http.StatusSeeOther)
    }
}

Register both alongside the list handler:

// HTML routes
mux.HandleFunc("GET /", handleBookmarksList(tmpl, store))
mux.HandleFunc("POST /bookmarks", handleBookmarksCreate(store))
mux.HandleFunc("POST /bookmarks/{id}/delete", handleBookmarksDelete(store))

That's it. Three routes give you a fully functional CRUD interface in the browser. The JSON API handles programmatic access, the HTML routes handle humans.

Security: Auto-Escaping

html/template automatically escapes output. If a bookmark title contains <script>alert("xss")</script>, it renders as literal text, not executable JavaScript. This is why you use html/template instead of text/template for HTML. The text/template package has the same syntax but no escaping.

If you need to render trusted HTML (you probably don't), use template.HTML:

data := struct {
    SafeHTML template.HTML
}{
    SafeHTML: template.HTML("<strong>trusted</strong>"),
}

Be careful with this. Only use it for content you control.

A Note on templ

The html/template package works, but it has real limitations. No type checking on template variables. No IDE support. Errors at runtime, not compile time. The templ project is a modern alternative that gives you type-safe, composable templates that compile to Go code. If you're building a serious web app with lots of templates, look into it. For a few pages on a mostly API project, html/template is fine.

Applying to Our Project

Add a templates/ directory and a handler that renders bookmarks as HTML:

bookmarks/
├── api/
│   ├── main.go
│   ├── handler.go
│   ├── handler_html.go     ← HTML handlers
│   └── store.go
└── templates/
    ├── layout.html
    └── list.html

The JSON API stays untouched. The HTML handler is a separate route that uses the same store. One data layer, two presentations. Register it alongside the API routes:

// API routes
mux.HandleFunc("GET /api/bookmarks", handleListBookmarks(store))
mux.HandleFunc("POST /api/bookmarks", handleCreateBookmark(store))

// HTML routes
mux.HandleFunc("GET /", handleBookmarksList(tmpl, store))
mux.HandleFunc("POST /bookmarks", handleBookmarksCreate(store))
mux.HandleFunc("POST /bookmarks/{id}/delete", handleBookmarksDelete(store))

When Templates Aren't Enough

Go templates work well for simple, server-rendered pages — an admin panel, a status dashboard, a basic list view. But they hit a wall fast when you need interactivity. Dropdown menus, live search, form validation without a page reload, dynamic UI updates — all of that requires JavaScript, and at that point you're fighting the template engine instead of working with it.

For more complex frontends, use a dedicated framework like Svelte, Vue, or React. Your Go server becomes a pure API (which it already is), and the frontend is a separate app that calls it.

This means two things you need to handle:

  1. API endpoints in the frontend. Your frontend framework needs to fetch data from your Go API. Set up proper API calls using fetch or a library like axios.

  2. CORS on your Go API. When the frontend runs on a different origin (e.g., localhost:5173 during development, or app.example.com in production), the browser blocks requests unless your API sends the right CORS headers. Set Access-Control-Allow-Origin to your frontend's actual origin. Never use * in production — it allows any website to call your API, which is a security risk.

// Development: allow your frontend dev server
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")

// Production: allow only your domain
w.Header().Set("Access-Control-Allow-Origin", "https://app.example.com")

The rule of thumb: if your page mostly displays data with minimal interaction, html/template is fine. If users are clicking, filtering, typing, and expecting instant feedback — use a frontend framework and keep Go as the API.

Key Takeaways

  • html/template parses templates with {{.Field}} placeholders and executes them with Go data
  • The dot (.) is the current context. Inside range, it shifts to the current element
  • Use define, template, and ParseFiles to build layouts with shared headers and footers
  • Register custom functions with template.FuncMap before parsing
  • Parse templates once at startup, not per request
  • html/template auto-escapes output. Use it instead of text/template for HTML
  • For larger projects with many templates, consider templ for type safety and IDE support

🚀 Ready to run?

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy