Templating and Rendering Content
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.FSStatic 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.
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
- Add a book detail page at
GET /books/{id}. - Show a flash-style error message when validation fails (query param or session).
- Embed templates with
//go:embedand confirm the binary serves pages without the source tree.