The build loop that kills flow
You write a blog post. You hit save. You wait three seconds for the server to restart. You spot a typo. You fix it. You wait again. The feedback loop is dragging you down. You want a tool that turns your Markdown into HTML in milliseconds, runs as a single binary, and doesn't require a database or a dependency tree the size of a small planet.
Go makes this trivial. A static site generator is just a file transformer. It reads source files, merges them with templates, and writes HTML to disk. No server needed for the build. No runtime overhead for the visitor. You get a directory of HTML files that any web server can serve instantly.
The mail-merge analogy
Think of a static site generator as a high-speed mail-merge. You have a template: a letter with blanks for the name, date, and message. You have a stack of data cards. The generator grabs a card, fills the blanks, prints the letter, and moves to the next one.
In Go, the template is html/template, the data is your content parsed into structs, and the output is a directory of HTML files. The standard library handles parsing, escaping, and file I/O. You don't need external tools. You write the transformation logic, and Go handles the rest.
Minimal example
Here's the skeleton. We define a template, load a file, pass data, and write the result. This demonstrates the core cycle: parse template, prepare data, execute, write output.
package main
import (
"fmt"
"html/template"
"os"
)
// Post holds the data for a single page.
// Exported fields (capitalized) are visible to the template engine.
type Post struct {
Title string
Body string
}
func main() {
// Parse the template file. Must panics if parsing fails,
// which stops the program immediately during development.
tmpl := template.Must(template.ParseFiles("layout.html"))
// Simulate data coming from a Markdown file.
data := Post{
Title: "Hello Go",
Body: "This is generated content.",
}
// Create the output file. 0644 gives owner read/write, others read.
out, err := os.Create("index.html")
if err != nil {
fmt.Println("Error creating file:", err)
return
}
// Ensure the file closes when we're done, even if execution panics.
defer out.Close()
// Execute the template, writing directly to the file.
// html/template automatically escapes HTML to prevent XSS attacks.
if err := tmpl.Execute(out, data); err != nil {
fmt.Println("Error executing template:", err)
}
}
template.Must is a common pattern. It wraps template.ParseFiles and panics on error. This is acceptable for template loading because a broken template is a fatal configuration error, not a runtime data error. The community accepts this panic-on-load pattern for templates. It keeps the code clean while ensuring you never run with a broken template.
How it works
At compile time, Go checks your types. The template engine doesn't run yet. template.ParseFiles runs at startup. It reads the template file, parses the syntax, and builds an internal tree. If you have a typo like {{ .Titl }}, ParseFiles returns an error. Must turns that error into a panic.
At runtime, Execute walks the template tree. It looks up fields on the Post struct. html/template is strict. If you try to inject <script>alert('xss')</script> in Body, it gets escaped to <script>. This is a security feature. The template engine treats all data as untrusted by default.
If you pass a struct without exported fields, the template engine can't see them. You'll get a runtime error like template: layout.html: "Title" is not a field of struct type main.Post. The fix is capital letters. Public names start with a capital letter. Private start lowercase. The template engine only sees exported fields. This matches Go's visibility rules everywhere else.
Realistic example
Real sites have multiple pages. You need to scan a directory, parse Markdown, and generate HTML for each post. This example loops over files, extracts a title and body, and generates the output. It also shows error handling that skips bad files instead of aborting the build.
// parsePost reads a file and extracts title and body.
// Real generators use a Markdown parser; this splits on newlines for brevity.
func parsePost(filename string) (Post, error) {
content, err := os.ReadFile(filename)
if err != nil {
// Wrap the error to preserve context about which file failed.
return Post{}, fmt.Errorf("reading %s: %w", filename, err)
}
lines := strings.Split(string(content), "\n")
if len(lines) == 0 {
return Post{}, fmt.Errorf("empty file: %s", filename)
}
// Treat the first line as the title and the rest as the body.
title := lines[0]
body := strings.Join(lines[1:], "\n")
// Generate a URL-friendly slug from the title.
slug := strings.ToLower(strings.ReplaceAll(title, " ", "-"))
return Post{Title: title, Slug: slug, Body: body}, nil
}
The parser handles I/O and basic text processing. Notice the error wrapping with %w. This allows callers to check the error chain later. The slug generation is naive; a production tool might use a library to handle special characters, but string replacement works for simple cases.
func main() {
// Parse the template once. Reusing the parsed template is much faster
// than parsing it inside the loop for every file.
tmpl := template.Must(template.ParseFiles("templates/layout.html"))
// Glob returns a list of matching file paths.
files, err := filepath.Glob("content/*.md")
if err != nil {
fmt.Println("Pattern error:", err)
return
}
for _, file := range files {
post, err := parsePost(file)
if err != nil {
// Log the error and move to the next file.
// Don't abort the whole build for one bad post.
fmt.Println("Skipping:", err)
continue
}
outPath := filepath.Join("public", "posts", post.Slug+".html")
// Create parent directories if they don't exist.
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
fmt.Println("Dir error:", err)
continue
}
out, err := os.Create(outPath)
if err != nil {
fmt.Println("Create error:", err)
continue
}
// Execute writes to the file. Close immediately after.
if err := tmpl.Execute(out, post); err != nil {
fmt.Println("Exec error:", err)
}
out.Close()
}
}
The loop processes each file sequentially. template.Must loads the template once outside the loop. Parsing templates is expensive; doing it per file would slow the build down. Reusing the parsed template is the standard approach.
if err != nil is verbose. The community accepts this boilerplate. It makes the unhappy path visible. You can't accidentally swallow an error in Go. Every error must be handled or returned. This discipline prevents silent failures in your build process. If a file fails to write, you see it. If you ignore the error, the compiler complains with err declared and not used.
Pitfalls and traps
Templates often need helper functions. Go lets you inject functions via FuncMap. This keeps logic out of the template and in Go code.
// FuncMap defines custom functions available inside templates.
// This keeps logic in Go code and templates declarative.
func customFuncs() template.FuncMap {
return template.FuncMap{
// Slugify converts a title to a URL-safe string.
"slugify": func(s string) string {
return strings.ToLower(strings.ReplaceAll(s, " ", "-"))
},
// Truncate cuts a string to a max length.
"truncate": func(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
},
}
}
// Usage in main:
// tmpl := template.Must(template.New("").Funcs(customFuncs()).ParseFiles("layout.html"))
Injecting functions via FuncMap is the idiomatic way to add logic. Templates should be declarative. If you find yourself writing complex if chains or loops in the template, move that logic to a Go function.
When you have hundreds of files, sequential generation gets slow. You can spawn a goroutine per file. But beware the loop variable trap. In Go versions before 1.22, the loop variable is shared across iterations. If you capture file in a goroutine, all goroutines might process the last file. The compiler rejects this now with loop variable i captured by func literal. Upgrade to Go 1.22+ or shadow the variable with file := file inside the loop.
html/template is your shield. text/template is for non-HTML output. If you use text/template for HTML, the compiler accepts it, but your browser renders scripts. You get no escaping. The error isn't a compile error; it's a security hole. Use html/template for HTML output. Always.
template.Must is great for development. In production, loading templates from disk carries risk. If the file is missing, Must panics with panic: template: pattern matches no files. A panic crashes the process. In production, handle the error gracefully. Return a 500 error or use a fallback template. Must is for dev. Handle errors in prod.
Go code looks the same everywhere. Run gofmt on save. Don't argue about braces or indentation. The tool decides. This convention saves time and keeps the codebase consistent. Most editors run gofmt automatically. Trust the tool.
Decision matrix
Use a static site generator when you have content that changes infrequently and you want zero server costs.
Use a headless CMS with a static generator when you need a dashboard for non-technical writers to submit content.
Use a traditional web framework when your site requires real-time updates, user sessions, or complex database queries on every request.
Use a single-page application framework when the site is essentially a dashboard with heavy client-side interactivity.
html/template is your shield. text/template is for non-HTML output. Pick the right tool.