Skip to main content

Static Files and HTML Templates in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Go ships with everything needed to build server-rendered web applications: html/template for safe HTML generation and net/http for serving static assets. The html/template package is specifically designed to prevent cross-site scripting (XSS) — it automatically escapes values based on context (HTML, URL, CSS, JavaScript), not just a blanket escape-everything approach.

This guide covers static file serving, template syntax, caching for performance, and the template composition patterns used in real applications.

For API-focused HTTP patterns see Go building REST APIs.

Serving Static Files

http.FileServer serves an entire directory tree. Combine it with http.StripPrefix to remove the URL prefix before looking up files:

// Serve files from ./static/ at URL path /static/
// Request: GET /static/css/style.css
// File:    ./static/css/style.css
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))))

For a single file (robots.txt, favicon.ico):

mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "./static/favicon.ico")
})

Security note: http.FileServer with http.Dir only serves files within that directory — path traversal (../../../etc/passwd) is blocked. But if you build paths yourself with filepath.Join(baseDir, r.URL.Path), you must validate that the result stays within baseDir.

Cache Headers for Static Assets

Static assets with a content hash in their filename (e.g., app.abc123.js) can be cached indefinitely:

mux.Handle("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Long cache for hashed assets, short cache for others
    w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
    http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
}))

For non-hashed assets like /favicon.ico, use a shorter cache duration: max-age=3600.

HTML Templates: Use html/template, Not text/template

The html/template package wraps text/template with automatic context-aware escaping. Always use html/template for HTML output — never text/template. The difference:

// text/template: data is inserted verbatim — XSS vulnerability
// html/template: data is escaped based on context — safe by default
import "html/template"

tmpl := template.Must(template.ParseFiles("index.html"))
data := struct{ UserInput string }{"<script>alert('xss')</script>"}
tmpl.Execute(w, data)
// html/template outputs: &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
// text/template would output: <script>alert('xss')</script>  ← DANGEROUS

The escaping is context-sensitive. Values inside <a href="{{.URL}}"> are URL-escaped; values inside <script> are JavaScript-escaped; values in HTML text nodes are HTML-escaped. The template engine tracks context as it parses.

Template Syntax

<!-- index.html -->
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>

<!-- Variable output -->
<h1>{{.Heading}}</h1>

<!-- Conditionals -->
{{if .IsLoggedIn}}
    <p>Welcome, {{.Username}}!</p>
{{else}}
    <p><a href="/login">Log in</a></p>
{{end}}

<!-- Loops -->
<ul>
{{range .Items}}
    <li>{{.Name}} — ${{printf "%.2f" .Price}}</li>
{{else}}
    <li>No items available.</li>
{{end}}
</ul>

<!-- With — enters the value's scope -->
{{with .CurrentUser}}
    <p>Logged in as {{.Name}} ({{.Email}})</p>
{{end}}

<!-- Pipeline — passes value to next function -->
<p>{{.Description | html | truncate 200}}</p>

</body>
</html>

Key action keywords:

  • {{if .Condition}}...{{else}}...{{end}} — conditionals
  • {{range .Slice}}...{{else}}...{{end}} — loops over slices, arrays, maps; {{else}} runs when empty
  • {{with .Value}}...{{end}} — sets . to .Value inside the block; skips if nil/zero
  • {{.FieldName}} — accesses the named field of the current dot value
  • {{$var := .Value}} — assigns to a local variable

Template Caching

Parsing templates is slow — it reads files and compiles them. Parse once at startup, reuse on every request:

var templates *template.Template

func init() {
    // template.Must panics at startup if templates fail to parse
    // This is appropriate — a broken template is a deployment error, not a runtime error
    templates = template.Must(template.ParseGlob("templates/*.html"))
}

func renderTemplate(w http.ResponseWriter, name string, data any) {
    if err := templates.ExecuteTemplate(w, name, data); err != nil {
        // At this point, the response header may already be sent
        // Log the error but we can't change the status code
        slog.Error("template execution failed",
            slog.String("template", name),
            slog.Any("error", err))
    }
}

// Usage in handler
func homePage(w http.ResponseWriter, r *http.Request) {
    renderTemplate(w, "home.html", PageData{
        Title: "Home",
        User:  currentUser(r),
    })
}

In development, you may want to re-parse on every request to see changes immediately. Wrap it in a flag:

var devMode = os.Getenv("DEV_MODE") == "true"

func getTemplates() *template.Template {
    if devMode {
        return template.Must(template.ParseGlob("templates/*.html"))
    }
    return templates  // cached
}

Custom Template Functions

The FuncMap adds custom functions callable from templates:

import (
    "html/template"
    "time"
)

func buildTemplates() *template.Template {
    funcMap := template.FuncMap{
        // Format a time.Time as a human-readable date
        "formatDate": func(t time.Time) string {
            return t.Format("January 2, 2006")
        },
        // Format cents (int) as dollars
        "formatPrice": func(cents int) string {
            return fmt.Sprintf("$%.2f", float64(cents)/100)
        },
        // Truncate a string with an ellipsis
        "truncate": func(n int, s string) string {
            if len(s) <= n {
                return s
            }
            return s[:n] + "…"
        },
        // Mark a string as safe HTML (bypasses escaping — use carefully)
        "safeHTML": func(s string) template.HTML {
            return template.HTML(s)
        },
    }

    return template.Must(
        template.New("").Funcs(funcMap).ParseGlob("templates/*.html"),
    )
}

Usage in templates:

<p>Posted on {{.CreatedAt | formatDate}}</p>
<p>Price: {{.PriceCents | formatPrice}}</p>
<p>{{.Description | truncate 150}}</p>

safeHTML is an escape hatch — only use it for trusted HTML that has already been sanitized. Using it on user-provided content reintroduces XSS.

Template Composition: define and block

Large applications need shared layouts (header, nav, footer) with per-page content. Use {{define}} to name template blocks and {{block}} / {{template}} to compose them:

<!-- templates/layout.html -->
{{define "layout"}}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>{{block "title" .}}My Site{{end}}</title>
    <link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
    {{template "nav" .}}
    <main>{{block "content" .}}{{end}}</main>
    {{template "footer" .}}
</body>
</html>
{{end}}

{{define "nav"}}
<nav>
    <a href="/">Home</a>
    {{if .IsLoggedIn}}<a href="/logout">Log out</a>{{else}}<a href="/login">Log in</a>{{end}}
</nav>
{{end}}

{{define "footer"}}
<footer><p>© 2026 My Site</p></footer>
{{end}}
<!-- templates/home.html -->
{{template "layout" .}}

{{define "title"}}Home — My Site{{end}}

{{define "content"}}
<h1>Welcome, {{.Username}}!</h1>
<ul>
{{range .Posts}}
    <li><a href="/posts/{{.Slug}}">{{.Title}}</a> — {{.CreatedAt | formatDate}}</li>
{{end}}
</ul>
{{end}}
func homePage(w http.ResponseWriter, r *http.Request) {
    // templates.ExecuteTemplate starts with the named template
    if err := templates.ExecuteTemplate(w, "layout", HomePageData{
        Username: currentUser(r).Name,
        IsLoggedIn: true,
        Posts: getRecentPosts(),
    }); err != nil {
        slog.Error("template error", slog.Any("error", err))
    }
}

{{block "title" .}}My Site{{end}} defines a block with a default value. If a child template defines {{define "title"}}...{{end}}, that overrides the default. If not, the default is used.

Preventing XSS

html/template prevents XSS for standard cases automatically. Two things can reintroduce it:

  1. Using template.HTML, template.URL, or template.JS — these types bypass escaping. Only use them for values you’ve sanitized yourself.

  2. Inserting user content into JavaScript blocks — html/template escapes for JavaScript context, but if you’re dynamically generating JS logic (not just data), review carefully.

For user-generated content that should allow some HTML (rich text editors), use a whitelist-based HTML sanitizer before inserting into templates — github.com/microcosm-cc/bluemonday is the standard Go choice.

Summary

  • Always use html/template, never text/template, for HTML output — the escaping is automatic and context-aware
  • http.FileServer with http.Dir is safe from path traversal — use it instead of serving files manually
  • Parse templates once at startup with template.Must; re-parse in dev mode for instant feedback
  • Add custom functions via FuncMap before calling ParseGlob — functions must be registered before parsing
  • {{define}} and {{block}} enable layout/page composition without repeating HTML structure
  • template.HTML(s) bypasses escaping — only use it for content you’ve sanitized through a trusted sanitizer

Resources

Comments

👍 Was this article helpful?