Back to blog

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/template is for text output, while html/template is for HTML output. Because it provides XSS protection for HTML, html/template should 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 with block. For example, if base.html contains {{block "content" .}}...{{end}}, then home.html can 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, or fmtDate to format data. You define them on the Go side with template.FuncMap and add them to the template before parsing with Parse*.

  • Auto-escape: html/template automatically 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 with template.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 content blocks; 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 -p

tailwind.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 --watch

Production build (minify):

npx tailwindcss -i ./assets/tailwind.css -o ./public/app.css --minify

Example 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/template performs 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/block name 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 .html file changes in the views folder.

  • 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 = true

Vite 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 of Execute → layouts won't work.

  • Calling Funcs() after Parse* → helpers won't be recognized.

  • Using the same define name incorrectly in two different files → override chaos.

  • Expecting .Title in the template but not providing Title on the Go side → zero strings 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, and hx-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