Server Side Render example
A multi-page guestbook rendered by Go's html/template, demonstrating layout inheritance, the POST–Redirect–GET pattern with flash messages, form re-population on validation failure, embedded static assets via embed.FS, and a themed 404 page. Reach for this example when serving HTML pages directly from MuxMaster — the same pattern powers this documentation website.
Step 1 — Embed templates and static files into the binary
embed.FS reads the templates/ and static/ directories at compile time and bakes them into the binary. The deployment artefact is a single executable; there is no filesystem dependency at runtime.
//go:embed templates static
var files embed.FS
This is the same primitive the documentation website uses to embed /content/ — see embed.go at the repository root.
Step 2 — Pre-parse one *template.Template per page
Every page is parsed at startup as base.html + <page>.html together, so each template set has exactly one definition per {{block "content"}} and {{block "title"}}. There are no runtime conflicts between pages and no per-request parsing cost.
type Templates struct {
pages map[string]*template.Template
notFound *template.Template
}
func loadTemplates() *Templates {
base := "templates/base.html"
pages := make(map[string]*template.Template)
for _, name := range []string{"home", "about", "guestbook"} {
pages[name] = template.Must(template.ParseFS(
files, base, "templates/"+name+".html",
))
}
return &Templates{
pages: pages,
notFound: template.Must(template.ParseFS(files, "templates/404.html")),
}
}
template.Must panics at startup if any template fails to parse; the binary refuses to come up with broken assets, which is the right failure mode for static content.
Step 3 — Define render and renderNotFound
The render helper looks up the page, sets the content type, and executes the base template (which inherits from <page>.html via the block definitions). When Execute fails after headers have been sent, the only honest action is to log — the response is already partially on the wire.
func (t *Templates) render(w http.ResponseWriter, name string, data PageData) {
tmpl, ok := t.pages[name]
if !ok {
http.Error(w, "unknown template: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
// Headers are already sent — we can only log at this point.
slog.Error("template render error", "page", name, "err", err)
}
}
renderNotFound is the same pattern minus the data payload — the 404 template is parsed standalone so it has no dependency on any page-specific block.
Step 4 — Define the page data struct
PageData is the value passed to every Execute. It carries flash messages, form values for re-population on validation failure, per-field errors, and the domain payload. Templates access fields with {{.Flash}}, {{.Entries}}, etc.
type PageData struct {
Flash string // transient message shown once
FlashType string // CSS class: "success" or "error"
Entries []*Entry // guestbook entries
Form map[string]string // re-populated form values after a failed POST
Errors map[string]string // per-field validation errors
}
A flat struct beats a nested view-model for templating: every field shows up as a top-level dot expression, which is the most readable form for designers reading the templates without writing Go.
Step 5 — Validate the guestbook form
Validation is a pure function returning a formErrors map (field name → message) and a boolean. The handler decides what to do on failure (re-render the page, in this example); the validator stays free of HTTP concerns.
func validateGuestbook(name, message string) (formErrors, bool) {
errs := make(formErrors)
if strings.TrimSpace(name) == "" {
errs["name"] = "Name is required."
} else if len(name) > 100 {
errs["name"] = "Name must be 100 characters or fewer."
}
if strings.TrimSpace(message) == "" {
errs["message"] = "Message is required."
} else if len(message) > 1000 {
errs["message"] = "Message must be 1000 characters or fewer."
}
return errs, len(errs) == 0
}
Returning the map of errors (rather than a single error) is what enables per-field highlighting in the re-rendered form — the template loops over .Errors.name, .Errors.message and styles each input independently.
Step 6 — Construct the router and wire the themed 404 + global middleware
r.NotFound is the override slot for the dispatcher's not-found handler. The example renders the themed 404 template; everything else stays consistent with the other examples (RequestID, Logger, Recoverer, plus CleanPath in Pre so URLs with double slashes are normalised before route matching).
r := mm.New()
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
tmpl.renderNotFound(w)
})
r.Use(
mw.RequestID(),
mw.Logger(os.Stdout),
mw.RecovererWithLogger(log),
)
r.Pre(mw.CleanPath())
CleanPath belongs in Pre because it must run before the radix tree resolves the URL — the cleaned path is what gets matched.
Step 7 — Serve the embedded /static/* files
fs.Sub re-roots the embedded filesystem at "static/" so ServeFiles receives paths like /style.css (the catch-all *filepath value) and finds the file directly. No filesystem layout decisions leak into the URL space.
staticFS, err := fs.Sub(files, "static")
if err != nil {
log.Error("cannot create static sub-FS", "err", err)
os.Exit(1)
}
r.ServeFiles("/static/*filepath", http.FS(staticFS))
ServeFiles handles Content-Type from the file extension, conditional GETs (If-None-Match, If-Modified-Since), and Range requests — there is no need to write any of that by hand.
Step 8 — Register the GET handlers (home, about, guestbook list)
The /guestbook GET reads the ?ok=1 query parameter the POST handler redirects to and surfaces it as a flash message in the response. No server-side session is required; the redirect URL is the entire mechanism.
r.GET("/", func(w http.ResponseWriter, _ *http.Request) {
tmpl.render(w, "home", PageData{})
})
r.GET("/about", func(w http.ResponseWriter, _ *http.Request) {
tmpl.render(w, "about", PageData{})
})
r.GET("/guestbook", func(w http.ResponseWriter, r *http.Request) {
data := PageData{Entries: store.all()}
if r.URL.Query().Get("ok") == "1" {
data.Flash = "Your entry has been added — thank you!"
data.FlashType = "success"
}
tmpl.render(w, "guestbook", data)
})
Storing flash state in the URL keeps the example self-contained — the moment the user navigates away, the flag is gone.
Step 9 — Apply POST–Redirect–GET on the form submission
The POST handler validates, stores on success, then redirects to GET (303 See Other). On validation failure the form re-renders inline with the submitted values (Form map) and per-field errors (Errors map) so the user loses no input.
r.POST("/guestbook", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.FormValue("name")
message := r.FormValue("message")
errs, ok := validateGuestbook(name, message)
if !ok {
// Re-render the form with submitted values and error messages.
tmpl.render(w, "guestbook", PageData{
Entries: store.all(),
Form: map[string]string{"name": name, "message": message},
Errors: errs,
})
return
}
store.add(name, message)
// Redirect to GET to prevent duplicate submissions on browser refresh.
http.Redirect(w, r, "/guestbook?ok=1", http.StatusSeeOther)
})
The 303 redirect is what defeats the "Resubmit form?" browser dialog: the browser remembers the redirect target as a GET, so refreshing the page re-fetches the list rather than re-submitting the form.
Step 10 — Serve with hardened timeouts
The same timeout set as the other examples — closes the slowloris vector. Production deployments should also wrap the server start in a goroutine and drain on SIGINT/SIGTERM (see the graceful-shutdown example).
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 30 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
Setting these on the surrounding http.Server is the application's job — MuxMaster is an http.Handler and cannot configure them itself.
Common questions
How do I render an HTML template from a MuxMaster handler?
Pre-parse one *template.Template per page at startup (so there is no runtime parse cost), then call tmpl.ExecuteTemplate(w, "base", data) from the handler. The example wraps this in a Templates.render helper that also sets Content-Type: text/html; charset=utf-8 and logs render errors after headers are sent.
How do I show a flash message exactly once after a successful POST?
Use POST–Redirect–GET: the POST handler stores the entry and replies with 303 See Other to a URL carrying a ?ok=1 query parameter; the GET handler reads the parameter and renders the flash message. The example uses this pattern on /guestbook so refreshing the page does not re-submit the form.
How do I re-populate the form when validation fails?
Pass the submitted values back to the template via PageData.Form (a map[string]string) and the per-field errors via PageData.Errors. The template reads them with {{.Form.name}} and {{.Errors.name}}. The example's validator returns the map directly so the handler does not have to translate error strings field-by-field.
Upstream source
Every code excerpt above is lifted verbatim from examples/server-side-render/main.go at the v1.1.0 tag. The upstream directory also contains the templates/ (base, home, about, guestbook, 404) and static/style.css files the example embeds via //go:embed.