Part 3: Templates & Layouts¶
In this part you'll add HTML templates, a Bootstrap-styled layout, and views that render question lists and detail pages.
Source code: tutorial/step03/
How Templates Work in Burrow¶
Burrow builds a global template set at startup by collecting templates from all apps that implement HasTemplates. Each template file uses {{ define "appname/template" }} to declare its name. When you call RenderTemplate(), it looks up the template by name, executes it, and wraps the result in a layout (if one is set).
Add Templates to the Polls App¶
Create the template directory for the polls app:
Implement the Interfaces¶
Add the following imports to internal/polls/polls.go (alongside the existing ones from Part 2):
Then add the interface implementations. The polls app now implements HasTemplates, HasRoutes, and HasNavItems:
//go:embed templates
var templateFS embed.FS
func (a *App) TemplateFS() fs.FS {
sub, _ := fs.Sub(templateFS, "templates")
return sub
}
func (a *App) NavItems() []burrow.NavItem {
return []burrow.NavItem{
{Label: "Polls", URL: "/polls", Position: 10},
}
}
TemplateFS() returns the embedded templates/ directory. Burrow walks this filesystem and parses all .html files into the global template set.
Write the Templates¶
Create internal/polls/templates/polls/list.html:
{{ define "polls/list" -}}
<div class="container py-4">
<h1>Polls</h1>
{{ if .Questions -}}
<div class="list-group">
{{ range .Questions -}}
<a href="/polls/{{ .ID }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ .Text }}</h5>
<small class="text-body-secondary">
{{ .PublishedAt.Format "2 Jan 2006" }}
</small>
</div>
</a>
{{ end -}}
</div>
{{ else -}}
<div class="alert alert-info">No polls available yet.</div>
{{ end -}}
</div>
{{- end }}
Create internal/polls/templates/polls/detail.html:
{{ define "polls/detail" -}}
<div class="container py-4">
<h1>{{ .Question.Text }}</h1>
<ul class="list-group mb-3">
{{ range .Question.Choices -}}
<li class="list-group-item">{{ .Text }}</li>
{{ end -}}
</ul>
<a href="/polls" class="btn btn-secondary">« Back to polls</a>
</div>
{{- end }}
And internal/polls/templates/polls/results.html:
{{ define "polls/results" -}}
<div class="container py-4">
<h1>Results: {{ .Question.Text }}</h1>
<ul class="list-group mb-3">
{{ range .Question.Choices -}}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ .Text }}
<span class="badge text-bg-primary rounded-pill">
{{ .Votes }} vote{{ if ne .Votes 1 }}s{{ end }}
</span>
</li>
{{ end -}}
</ul>
<a href="/polls/{{ .Question.ID }}" class="btn btn-primary">Vote again</a>
<a href="/polls" class="btn btn-secondary">« Back to polls</a>
</div>
{{- end }}
Update the App Struct¶
The app needs a handlers field and must initialise it during Register(). Update the App struct and Register() method in internal/polls/polls.go:
type App struct {
repo *Repository
handlers *Handlers
}
func (a *App) Register(cfg *burrow.AppConfig) error {
a.repo = NewRepository(cfg.DB)
a.handlers = &Handlers{repo: a.repo}
return nil
}
Add Handlers and Routes¶
Still in internal/polls/polls.go, add the Handlers struct and route registration:
type Handlers struct {
repo *Repository
}
func (h *Handlers) List(w http.ResponseWriter, r *http.Request) error {
questions, err := h.repo.ListQuestions(r.Context())
if err != nil {
return burrow.NewHTTPError(http.StatusInternalServerError, "failed to list questions")
}
return burrow.RenderTemplate(w, r, http.StatusOK, "polls/list", map[string]any{
"Title": "Polls",
"Questions": questions,
})
}
func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return burrow.NewHTTPError(http.StatusBadRequest, "invalid question ID")
}
question, err := h.repo.GetQuestion(r.Context(), id)
if err != nil {
return burrow.NewHTTPError(http.StatusNotFound, "question not found")
}
return burrow.RenderTemplate(w, r, http.StatusOK, "polls/detail", map[string]any{
"Title": question.Text,
"Question": question,
})
}
func (h *Handlers) Results(w http.ResponseWriter, r *http.Request) error {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return burrow.NewHTTPError(http.StatusBadRequest, "invalid question ID")
}
question, err := h.repo.GetQuestion(r.Context(), id)
if err != nil {
return burrow.NewHTTPError(http.StatusNotFound, "question not found")
}
return burrow.RenderTemplate(w, r, http.StatusOK, "polls/results", map[string]any{
"Title": fmt.Sprintf("Results: %s", question.Text),
"Question": question,
})
}
func (a *App) Routes(r chi.Router) {
r.Route("/polls", func(r chi.Router) {
r.Get("/", burrow.Handle(a.handlers.List))
r.Get("/{id}", burrow.Handle(a.handlers.Detail))
r.Get("/{id}/results", burrow.Handle(a.handlers.Results))
})
}
Create a Pages App with Layout¶
The pages app provides the site layout and homepage. Create the directories first:
Create internal/pages/pages.go:
package pages
import (
"embed"
"html/template"
"io/fs"
"net/http"
"github.com/oliverandrich/burrow"
"github.com/go-chi/chi/v5"
)
//go:embed templates
var templateFS embed.FS
type App struct{}
func New() *App { return &App{} }
func (a *App) Name() string { return "pages" }
func (a *App) Register(_ *burrow.AppConfig) error { return nil }
func (a *App) TemplateFS() fs.FS {
sub, _ := fs.Sub(templateFS, "templates")
return sub
}
func (a *App) NavItems() []burrow.NavItem {
return []burrow.NavItem{
{Label: "Home", URL: "/", Position: 0},
}
}
func (a *App) Routes(r chi.Router) {
r.Get("/", burrow.Handle(func(w http.ResponseWriter, r *http.Request) error {
return burrow.RenderTemplate(w, r, http.StatusOK, "pages/home", map[string]any{
"Title": "Welcome to Polls",
})
}))
}
The Layout Function¶
The layout wraps every page in a consistent HTML shell:
func Layout() burrow.LayoutFunc {
return func(w http.ResponseWriter, r *http.Request, code int,
content template.HTML, data map[string]any) error {
exec := burrow.TemplateExecutorFromContext(r.Context())
if exec == nil {
return burrow.HTML(w, code, string(content))
}
layoutData := map[string]any{
"Content": content,
"NavItems": burrow.NavItems(r.Context()),
}
if title, ok := data["Title"]; ok {
layoutData["Title"] = title
}
rendered, err := exec(r, "app/layout", layoutData)
if err != nil {
return err
}
return burrow.HTML(w, code, string(rendered))
}
}
When RenderTemplate() is called:
- It executes the named template (e.g.
"polls/list") to produce an HTML fragment - It checks if the request is an HTMX request — if so, it returns the fragment directly
- Otherwise, it calls the
LayoutFuncwhich wraps the fragment in the full page layout
The Layout Template¶
Create internal/pages/templates/app/layout.html:
{{ define "app/layout" -}}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ if .Title }}{{ .Title }} — {{ end }}Polls</title>
<link rel="stylesheet" href="{{ staticURL "bootstrap/bootstrap.min.css" }}">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary mb-4">
<div class="container">
<a class="navbar-brand" href="/">Polls</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav">
{{ range .NavItems -}}
<li class="nav-item">
<a class="nav-link" href="{{ .URL }}">{{ .Label }}</a>
</li>
{{ end -}}
</ul>
</div>
</div>
</nav>
<main class="container">
{{ .Content }}
</main>
<script src="{{ staticURL "bootstrap/bootstrap.bundle.min.js" }}"></script>
</body>
</html>
{{- end }}
The {{ staticURL }} function is provided by the staticfiles contrib app — it resolves filenames to content-hashed URLs for cache busting.
The Homepage Template¶
Create internal/pages/templates/pages/home.html:
{{ define "pages/home" -}}
<div class="px-4 py-5 text-center">
<h1 class="display-5 fw-bold">Welcome to Polls</h1>
<div class="col-lg-6 mx-auto">
<p class="lead mb-4">
A simple polling application built with the burrow framework.
</p>
<a href="/polls" class="btn btn-primary btn-lg">View Polls »</a>
</div>
</div>
{{- end }}
Update main.go¶
Replace your main.go with:
package main
import (
"context"
"embed"
"log"
"os"
"github.com/oliverandrich/burrow"
"github.com/oliverandrich/burrow/contrib/bootstrap"
"github.com/oliverandrich/burrow/contrib/htmx"
"github.com/oliverandrich/burrow/contrib/staticfiles"
"github.com/urfave/cli/v3"
"polls/internal/pages"
"polls/internal/polls"
)
var emptyFS embed.FS
func main() {
staticApp, err := staticfiles.New(emptyFS)
if err != nil {
log.Fatal(err)
}
srv := burrow.NewServer(
staticApp,
htmx.New(),
bootstrap.New(),
pages.New(),
polls.New(),
)
srv.SetLayout(pages.Layout())
cmd := &cli.Command{
Name: "polls",
Usage: "Polls tutorial application",
Version: "0.3.0",
Flags: srv.Flags(nil),
Action: srv.Run,
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
This replaces the homepageApp from Part 1 with proper apps:
staticfiles— serves static files with content-hashed URLshtmx— provides the htmx JavaScript library (required by Bootstrap app)bootstrap— provides Bootstrap 5 CSS/JS as static assetspages— homepage and layoutpolls— now with templates and routes
Run It¶
Open http://localhost:8080 — you'll see the Bootstrap-styled homepage. Click "View Polls" to see the (empty) polls list. There are no questions yet because we haven't added a way to create them.
Seeding test data
You can use the SQLite CLI to insert test data:
Refresh the page to see them appear.What You've Learnt¶
HasTemplates— apps contribute.htmltemplate files to the global template setRenderTemplate()— renders a named template, automatically wrapping in a layout for normal requests and returning fragments for HTMX requestsLayoutFunc— wraps page content in a full HTML document with navigation, scripts, and stylesstaticfilesandbootstrap— contrib apps handle CSS/JS assets with cache busting
Next¶
In Part 4, you'll add a voting form with CSRF protection, flash messages, and the redirect-after-POST pattern.