Clean UI with Go’s HTML Templates: Base, Partials, and FuncMaps
Go’s html/template` package helps us create clean and secure server-side HTML. With a layout system, we reduce repetitive code, and with FuncMap, we add small but effective helpers to our templates. We're also keeping the interface simple and elegant with TailwindCSS.
What Are We Solving?
-
Server-`side rendering: SEO-friendly, fast initial load, and a simple architecture.
-
XSS protection:
html/template's automatic escaping prevents accidental XSS vulnerabilities. -
Partials/patterns: Our layout + partials system eliminates copy-pasting.
-
Speed with Tailwind: Clean UI without being overwhelmed by a component library.
Core Concepts
-
text/template vs html/template: Go has two different template packages.
text/templateis for text output, whilehtml/templateis for HTML output. Because it provides XSS protection for HTML,html/templateshould always be preferred for web projects. -
define / block: These are used to set up a template inheritance structure. You define a template block with
define, and this block can be overridden in another file withblock. For example, ifbase.htmlcontains{{block "content" .}}...{{end}}, thenhome.htmlcan fill this block. This establishes a base → page relationship. -
template: This is used to call another template file. It’s often used to call partial files, like
{{template "partials/nav" .}}. This allows you to define repetitive structures (e.g., a navigation bar) in a single place. -
FuncMap: This is used to define custom functions within your templates. For example, you can add functions like
upper,truncate, orfmtDateto format data. You define them on the Go side withtemplate.FuncMapand add them to the template before parsing withParse*. -
Auto-escape:
html/templateautomatically encodes user-provided data. For example, malicious content like<script>is rendered as plain text, not as HTML. This provides protection against XSS attacks. However, you can disable escaping withtemplate.HTML—use with caution.
A simple example:
type PageData struct{ Title, User string }
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
tpl := template.Must(template.ParseFiles("views/home.html"))
_ = tpl.Execute(w, PageData{Title: "Homepage", User: "Uygar"})
})views/home.html:
<!doctype html>
<html>
<head><title>{{.Title}}</title></head>
<body><h1>Hello {{.User}}</h1></body>
</html>Project Structure and Getting Started
A quick skeleton:
/cmd/server/main.go
/internal/http/router.go
/views
/layouts/base.html
/partials/nav.html
/pages/home.html
/assets/tailwind.css
/public/app.css (build output)Layout & Partials System
views/layouts/base.html
{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{block "title" .}}Default Title{{end}}</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body class="bg-neutral-50 text-neutral-900">
{{template "partials/nav" .}}
<main class="container mx-auto max-w-4xl px-4 py-8">
{{block "content" .}}{{end}}
</main>
<footer class="py-8 text-center text-sm text-neutral-500">
© {{.Year}} MyBlog
</footer>
</body>
</html>
{{end}}views/partials/nav.html
{{define "partials/nav"}}
<header class="border-b bg-white/80 backdrop-blur">
<div
class="container mx-auto max-w-4xl px-4 h-14 flex items-center justify-between"
>
<a href="/" class="font-semibold">uygarceylan.net</a>
<nav class="flex gap-4 text-sm">
<a class="hover:underline" href="/">Home</a>
<a class="hover:underline" href="/posts">Blog</a>
</nav>
</div>
</header>
{{end}}views/pages/home.html
{{define "content"}}
<section class="space-y-6">
<h1 class="text-3xl font-bold">Hello {{.User}}</h1>
<p class="text-neutral-600">
This blog is rendered with Go `html/template`.
</p>
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<strong class="block mb-1">Tip:</strong>
Throw away repetitive HTML with layouts + partials.
</div>
</section>
{{end}}Small but critical: The page file only defines
contentblocks; the base wraps everything.
Helpers with FuncMap
On the Go side:
import (
"html/template"
"strings"
"time"
)
func newTemplates() *template.Template {
return template.Must(template.New("").Funcs(template.FuncMap{
"upper": strings.ToUpper,
"truncate": func(s string, n int) string {
if len(s) <= n { return s }
return s[:n] + "…"
},
"fmtDate": func(t time.Time) string {
return t.Format("02 Jan 2006")
},
}).ParseGlob("views/**/*.html"))
}Example usage:
<h2 class="text-xl">{{upper .Title}}</h2>
<p class="text-sm text-neutral-500">{{fmtDate .PublishedAt}}</p>
<p>{{truncate .Excerpt 120}}</p>We’ll integrate this into our code in other sections.
TailwindCSS Integration
Installation (requires Node):
yarn add -D tailwindcss@3 postcss autoprefixer
npx tailwindcss init -ptailwind.config.js
module.exports = {
content: ["./views/**/*.html", "./assets/**/*.js"],
theme: { extend: {} },
plugins: []
}/assets/tailwind.css
@tailwind base;
@tailwind components;
@tailwind utilities;
.container { @apply mx-auto px-4; }
.btn { @apply inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium border bg-white hover:bg-neutral-50; }
.input { @apply w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900/10; }
.card { @apply rounded-lg border bg-white p-4 shadow-sm; }Development build:
npx tailwindcss -i ./assets/tailwind.css -o ./public/app.css --watchProduction build (minify):
npx tailwindcss -i ./assets/tailwind.css -o ./public/app.css --minifyExample Page: “Blog List”
Go (data + render)
internal/http/router.go
type Post struct {
ID int
Title string
Excerpt string
ExcerptShort string
PublishedAt time.Time
PublishedAtStr string
}
type Router struct {
// render is a function that manages the HTTP response and processes the HTML template.
// This function expects an HTTP ResponseWriter, a template name, and data.
render func(http.ResponseWriter, string, any)
}
func New(render func(http.ResponseWriter, string, any)) http.Handler {
r := &Router{render: render}
mux := chi.NewRouter()
// Listens for GET requests for the homepage and blog list.
mux.Get("/home", r.Home)
mux.Get("/posts", r.PostList)
return mux
}
// Home prepares the necessary data for the homepage and sends it to the render function.
func (r *Router) Home(w http.ResponseWriter, _ *http.Request) {
// We gather the data to be sent to the template in a map.
data := map[string]any{
"Title": "Homepage",
"User": "Onur",
"Year": time.Now().Year(),
}
// We process the template named "home" (views/pages/home.html) with this data and send it as a response.
r.render(w, "home", data)
}
// PostList prepares the data for the list of blog posts.
func (r *Router) PostList(w http.ResponseWriter, _ *http.Request) {
// We create sample blog posts and add them to a slice.
posts := []Post{
{
ID: 1,
Title: "Getting Started with Go Templates",
Excerpt: "Template architecture, base and partial practices…",
ExcerptShort: "Template architecture, base and partial practices…",
PublishedAt: time.Now().AddDate(0, 0, -2),
PublishedAtStr: time.Now().AddDate(0, 0, -2).Format("02 Jan 2006"),
},
{
ID: 2,
Title: "Introduction to HTMX",
Excerpt: "Partial rendering and progressive enhancement…",
ExcerptShort: "Partial rendering and progressive enhancement…",
PublishedAt: time.Now().AddDate(0, 0, -1),
PublishedAtStr: time.Now().AddDate(0, 0, -1).Format("02 Jan 2006"),
},
}
// We prepare the data to be sent to the template.
data := map[string]any{
"Title": "Posts",
"Year": time.Now().Year(),
"Posts": posts,
}
// We process the template named "posts" (views/pages/posts.html) with this data.
r.render(w, "posts", data)
}views/pages/posts.html
{{define "content"}}
<section class="space-y-6">
<header class="flex items-end justify-between">
<h1 class="text-3xl font-bold">{{.Title}}</h1>
<div class="w-64">
<input class="input" placeholder="Search (dummy — HTMX in the next post)" />
</div>
</header>
<ul class="grid gap-4 md:grid-cols-2">
{{range .Posts}}
<li class="card">
<a class="block group" href="/posts/{{.ID}}">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold group-hover:underline">
{{.Title}}
</h2>
<span class="text-xs text-neutral-500">{{.PublishedAtStr}}</span>
</div>
<p class="mt-2 text-sm text-neutral-600">{{.ExcerptShort}}</p>
<div class="mt-4">
<span class="btn text-neutral-700">Read</span>
</div>
</a>
</li>
{{else}}
<li class="card">
<p class="italic text-neutral-600">No posts yet.</p>
</li>
{{end}}
</ul>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
<strong class="block mb-1">Next step:</strong>
We will make this list refresh as a _partial_ with HTMX.
</div>
</section>
{{end}}cmd/server/main.go
func render(w http.ResponseWriter, name string, data any) {
// The template.ParseFiles function reads and parses all template files one by one.
// In this example, templates are re-read and parsed on every request.
// In a production environment, it's more performant to do this only at application startup.
tpl := template.Must(template.ParseFiles(
"views/layouts/base.html",
"views/partials/nav.html",
"views/pages/"+name+".html",
))
// ExecuteTemplate calls the main (base) template and injects other templates (partials, content) into it.
if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
// If an error occurs, we return a server error (500).
http.Error(w, err.Error(), 500)
}
}
func main() {
mux := http.NewServeMux()
// We route the /app.css request to serve the file from the public directory.
mux.Handle("/app.css", http.FileServer(http.Dir("public")))
// When creating the router, we pass the render function above as a parameter.
app := routerpkg.New(render)
// We route all incoming HTTP requests (via the default "/" path) to our created router.
mux.Handle("/", app)
log.Println("http://localhost:8081")
_ = http.ListenAndServe("localhost:8081", mux)
}Security & Anti-Patterns
-
html/templateperforms automatic escaping, so you can safely render user data. -
Never make unverified HTML “safe” with
template.HTML. -
Remember: escaping works on
{{...}}output; you also need to handle inline string escapes correctly in JS events.
“I need to render raw HTML”:
-
Sanitize content in the back-office (e.g., using an allowlist).
-
Then provide it as
template.HTML—but be sure it's truly safe.
Challenges vs. Conveniences
Conveniences
-
Simple stack: Go + Templates + (later) HTMX/Alpine. No build complexity.
-
SEO and speed: Fast FCP with SSR, happy bots.
-
Security: Auto-escaping is secure by default.
Challenges
-
Heavy front-end interactions: Don’t expect the comfort of a full SPA. (Solution: enhance with HTMX/Alpine piece by piece.)
-
Template inheritance:
define/blockname clashes, knowing which block is overridden in which file—requires attention. -
Caching & hot-reload: Parsing on every request is fine for dev; for prod, you’ll want parse-caching or build-time embedding.
Bonus: Improving the Developer Experience (Vite + Air)
You can solve the lack of hot-reload with a single command.
Here’s the trick:
-
Vite → Compiles TailwindCSS + watches for
.htmlfile changes in theviewsfolder. -
Air → Watches for Go code changes and automatically restarts.
Air Installation
go install github.com/cosmtrek/air@latest.air.toml (example):
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/main ./cmd/server"
bin = "tmp/main"
include_ext = ["go", "html"]
exclude_dir = ["node_modules", "public"]
[log]
time = trueVite Configuration
vite.config.js
import { defineConfig } from 'vite'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
export default defineConfig({
server: {
watch: {
// let's watch .html files
paths: ['views/**/*.html']
}
},
css: {
postcss: {
plugins: [tailwindcss, autoprefixer]
}
}
})Single Command with Makefile
dev:
# Run Vite + Air simultaneously
concurrently "yarn dev" "air"package.json
{
"scripts": {
"dev": "vite",
"dev:css": "tailwindcss -i ./assets/tailwind.css -o ./public/app.css --watch"
},
"devDependencies": {
"vite": "^5.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"concurrently": "^8.0.0"
}
}Ideal Scenarios for Choosing html/template
Content-heavy sites
-
Works perfectly for page-focused projects like blogs, news sites, or documentation.
-
Provides fast initial loading and an SEO-friendly structure.
Admin panels and dashboards
-
You can create fast-responding pages with SSR.
-
The layout/partials structure ensures visual consistency.
Simple forms and CRUD operations
-
Suitable for form structures generated on the backend.
-
You can easily update an entire page or a portion of it after validation.
When to Consider Alternatives
Heavy client-side interactions
-
Offline modes
-
Complex form wizards
-
Multi-step, stateful UIs
For these scenarios, start with an SSR-based approach, then enhance the client side with solutions like HTMX or Alpine.js as needed.
Common Mistakes
-
Rendering HTML with
text/template→ XSS risk. -
Not calling
ExecuteTemplate("base", ...)instead ofExecute→ layouts won't work. -
Calling
Funcs()afterParse*→ helpers won't be recognized. -
Using the same
definename incorrectly in two different files → override chaos. -
Expecting
.Titlein the template but not providingTitleon the Go side →zerostrings and silent errors.
FAQ
Should template.Must remain in production?
template.Must is safe to use, but it can cause the application to panic and crash if there's a template parsing error. In a production environment, it's a good practice to parse templates once at application startup. This way, if there's an error, the service won't start, and you can catch the issue early.
Can I embed templates into the application binary?
Yes, you can use Go’s embed directive to include template files directly into your application's binary. This significantly simplifies the deployment process, as you don't need to manage separate template files.
Is Tailwind JIT heavy in production?
No. During the build process, the JIT engine purges all unused CSS classes, resulting in a minimal and highly optimized app.css file. This makes the production build very lightweight and fast.
How can I safely render HTML content?
You should only provide content that has been approved or sanitized to the template.HTML type. Never render unsanitized user-provided input directly. Rely on html/template's automatic escaping feature for all other content.
Conclusion
In this part, we:
-
Built a base/partials architecture with
html/template. -
Wrote helpers with FuncMap.
-
Created a “Blog List” page.
-
Discussed security and practical pitfalls.
What’s Next?
Part 2: Dynamic Partials with HTMX
In our next article, we’ll enrich our html/template system with HTMX.
We’ll build interactive features, like an input search box that updates only a portion of the page (e.g., the list area) without a full page reload:
-
Updating content without a page refresh
-
Basic HTMX concepts like
hx-get,hx-target, andhx-swap -
A list filtering example
-
Code-explained live demo snippets
We’ll see how to create SPA-like experiences using only HTML and Go, without any complex frameworks or build systems.
Source Code
- GitHub repo: https://github.com/uodev/go-htmx-blog