Templating and Rendering Content

Updated

September 8, 2026

Templating and Rendering Content

Overview

Not every client is JSON. Server-rendered HTML remains a clear way to ship admin UIs and simple Bookstore pages. Go’s html/template auto-escapes content by default—use it for user-facing HTML. Keep templates dumb: pass view models, not open database handles.

html/template vs text/template

Package Use
html/template Browsers (escapes HTML, JS, CSS contexts)
text/template Emails, configs, code gen—not HTML pages

Never put untrusted user input into text/template and serve as HTML.

Minimal page

internal/web/templates/layout.tmpl:

{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{block "title" .}}Bookstore{{end}}</title>
  <link rel="stylesheet" href="/static/site.css">
</head>
<body>
  <header><a href="/books">Bookstore</a></header>
  <main>
    {{block "content" .}}{{end}}
  </main>
</body>
</html>
{{end}}

internal/web/templates/books_list.tmpl:

{{define "title"}}Catalog{{end}}
{{define "content"}}
<h1>Books</h1>
<ul>
  {{range .Books}}
    <li><a href="/books/{{.ID}}">{{.Title}}</a> — {{.Author}}</li>
  {{else}}
    <li>No books yet.</li>
  {{end}}
</ul>
{{end}}

Parse once, execute many

package httpapi

import (
    "html/template"
    "io/fs"
    "net/http"
)

type Server struct {
    books store.BookRepository
    tmpl  *template.Template
}

func NewServer(books store.BookRepository, templateFS fs.FS) (*Server, error) {
    t, err := template.ParseFS(templateFS, "templates/*.tmpl")
    if err != nil {
        return nil, err
    }
    return &Server{books: books, tmpl: t}, nil
}

func (s *Server) booksPage(w http.ResponseWriter, r *http.Request) {
    items, err := s.books.List(r.Context())
    if err != nil {
        http.Error(w, "unavailable", http.StatusInternalServerError)
        return
    }
    data := struct {
        Books []domain.Book
    }{Books: items}

    w.Header().Set("Content-Type", "text/html; charset=utf-8")
    if err := s.tmpl.ExecuteTemplate(w, "layout", data); err != nil {
        // header may already be sent; log and move on
        slog.Error("template", "err", err)
    }
}

Embed templates in the binary:

//go:embed templates/* static/*
var webFS embed.FS

Static files

mux.Handle("GET /static/", http.StripPrefix("/static/",
    http.FileServer(http.FS(staticFS))))

Set long cache headers only for fingerprinted assets in production.

Forms (create book)

Template snippet:

<form method="POST" action="/books">
  <label>Title <input name="title" required></label>
  <label>Author <input name="author" required></label>
  <label>Price (cents) <input name="price_cents" type="number" min="0"></label>
  <button type="submit">Add book</button>
</form>

Handler:

func (s *Server) createBookForm(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        http.Error(w, "bad form", http.StatusBadRequest)
        return
    }
    price, _ := strconv.Atoi(r.FormValue("price_cents"))
    b := domain.Book{
        Title:  r.FormValue("title"),
        Author: r.FormValue("author"),
        Price:  price,
    }
    if err := b.Validate(); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    if _, err := s.books.Create(r.Context(), b); err != nil {
        http.Error(w, err.Error(), http.StatusConflict)
        return
    }
    http.Redirect(w, r, "/books", http.StatusSeeOther)
}

POST → redirect → GET avoids duplicate form submits on refresh.

Escaping and safety

html/template escapes {{.Title}}. If you must inject trusted HTML:

// only for trusted, sanitized HTML
type page struct {
    Body template.HTML
}

Treat template.HTML as a sharp tool—never cast raw user input.

Sharing data with JSON APIs

Prefer one domain layer for both HTML and JSON:

  GET /books          → HTML list (templates)
  GET /api/books      → JSON list (same BookRepository)

Do not reimplement list logic twice.

Partial rendering / HTMX (optional)

If you later adopt HTMX (see part 08-web), return fragments from the same templates ({{define "book_row"}}) instead of full layouts. The structure here still holds: parse once, execute named templates.

Rules of thumb

Do Don’t
Parse templates at startup Parse from disk on every request
Pass view structs Pass *sql.DB into templates
Use html/template for pages Build HTML with string concat + user data
Redirect after POST Re-render POST responses that create data

Try next

  1. Add a book detail page at GET /books/{id}.
  2. Show a flash-style error message when validation fails (query param or session).
  3. Embed templates with //go:embed and confirm the binary serves pages without the source tree.