Go HTML Template ile Temiz UI: Base, Partial, FuncMap

Go’da html/template ile sunucu tarafı HTML üretimini daha düzenli ve güvenli hale getiriyoruz. Layout sistemiyle tekrar eden yapıları azaltıyor, FuncMap ile şablonlara küçük ama etkili yardımcılar ekliyoruz. Arayüzü de TailwindCSS ile sade ve şık tutuyoruz.

🔧 Neyi Çözüyoruz?

  • Sunucu-taraflı render: SEO dostu, hızlı ilk yükleme, sade mimari.

  • XSS koruması: html/template otomatik escape ile “yanlışlıkla XSS” şovunu bitiriyor.

  • Parça/kalıp: Layout + partial düzeniyle kopyala-yapıştır çilesi yok.

  • Tailwind ile hız: Component kütüphanesine boğulmadan temiz UI.

🧠 Temel Kavramlar

  • text/template vs html/template

    Go'da iki farklı template paketi var. text/template metin çıktıları için, html/template ise HTML çıktıları için kullanılır. HTML'de XSS koruması sağladığı için web projelerinde her zaman html/template tercih edilir.

  • define / block

    Template miras yapısını kurmak için kullanılır. define ile bir şablon bloğu tanımlanır, block ile bu blok başka bir dosyada override edilebilir. Örneğin base.html içinde {{block "content" .}}...{{end}} varsa, home.html bu bloğu doldurur. Böylece base → sayfa ilişkisi kurulur.

  • template

    Başka bir template dosyasını çağırmak için kullanılır. Genellikle partial (parça) dosyaları çağırmak için kullanılır: {{template "partials/nav" .}}. Bu sayede tekrar eden yapılar (örn. navigasyon) tek bir yerde tanımlanır.

  • FuncMap

    Template içinde özel fonksiyonlar tanımlamak için kullanılır. Örneğin upper, truncate, fmtDate gibi fonksiyonlarla veriyi biçimlendirebilirsin. Go tarafında template.FuncMap ile tanımlanır ve Parse* öncesinde şablona eklenir.

  • Otomatik Escape (auto-escape)

    html/template kullanıcıdan gelen verileri otomatik olarak encode eder. Örneğin <script> gibi zararlı içerikler HTML olarak değil, düz metin olarak basılır. Bu sayede XSS saldırılarına karşı koruma sağlanır. Ancak template.HTML ile escape’i devre dışı bırakmak mümkündür — dikkatli kullanılmalıdır.

Basit örnek:

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: "Anasayfa", User: "Uygar"})
})

views/home.html:

<!doctype html>
<html>
<head><title>{{.Title}}</title></head>
<body><h1>Merhaba {{.User}}</h1></body>
</html>

🗂 Proje Yapısı ve Başlangıç

Hızlı bir iskelet:

/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 çıkışı)

🏗 Layout & Partial Sistemi

views/layouts/base.html

{{define "base"}}
<!DOCTYPE html>
<html lang="tr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>{{block "title" .}}Varsayılan Başlık{{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="/">Ana Sayfa</a>
      <a class="hover:underline" href="/posts">Bloglar</a>
    </nav>
  </div>
</header>
{{end}}

views/pages/home.html

{{define "content"}}
<section class="space-y-6">
  <h1 class="text-3xl font-bold">Merhaba {{.User}}</h1>
  <p class="text-neutral-600">
    Bu blog Go <code>html/template</code> ile render ediliyor.
  </p>
  <div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
    <strong class="block mb-1">İpucu:</strong>
    Layout + partial ile tekrar eden HTML’i çöpe at.
  </div>
</section>
{{end}}

Küçük ama kritik: Sayfa dosyası sadece content block’larını tanımlar; base her şeyi çerçeveler.

🧰 FuncMap ile Helper’lar

Go tarafında:

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"))
}

Kullanımı (örnek):

<h2 class="text-xl">{{upper .Title}}</h2>
<p class="text-sm text-neutral-500">{{fmtDate .PublishedAt}}</p>
<p>{{truncate .Excerpt 120}}</p>

Diğer bölümlerde burayı kodumuza entegre edeceğiz

🎨 TailwindCSS Entegrasyonu

Kurulum (Node gerektirir):

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; }

Geliştirme build:

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

Prod build (minify):

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

🧪 Örnek Sayfa: “Blog Listesi”

Go (veri + 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, HTTP yanıtını yöneten ve HTML şablonunu işleyen fonksiyondur.
    // Bu fonksiyon, bir HTTP yanıt yazıcısı (ResponseWriter), bir şablon adı ve veriyi bekler.
    render func(http.ResponseWriter, string, any)
}

func New(render func(http.ResponseWriter, string, any)) http.Handler {
    r := &Router{render: render}
    mux := chi.NewRouter()

    // Ana sayfa ve blog listesi için GET isteklerini dinler.
    mux.Get("/home", r.Home)
    mux.Get("/posts", r.PostList)

    return mux
}

// Home fonksiyonu, ana sayfa için gerekli veriyi hazırlar ve render fonksiyonuna gönderir.
func (r *Router) Home(w http.ResponseWriter, _ *http.Request) {
    // Şablona gönderilecek veriyi bir map içinde topluyoruz.
    data := map[string]any{
        "Title": "Anasayfa",
        "User":  "Onur",
        "Year":  time.Now().Year(),
    }
    // "home" adlı şablonu (views/pages/home.html) bu veriyle işliyor ve yanıt olarak gönderiyoruz.
    r.render(w, "home", data)
}

// PostList fonksiyonu, blog yazıları listesi için veriyi hazırlar.
func (r *Router) PostList(w http.ResponseWriter, _ *http.Request) {
    // Örnek blog yazıları oluşturup bir slice içine ekliyoruz.
    posts := []Post{
        {
            ID:             1,
            Title:          "Go Templates ile Başlarken",
            Excerpt:        "Template mimarisi, base ve partial pratikleri…",
            ExcerptShort:   "Template mimarisi, base ve partial pratikleri…",
            PublishedAt:    time.Now().AddDate(0, 0, -2),
            PublishedAtStr: time.Now().AddDate(0, 0, -2).Format("02 Jan 2006"),
        },
        {
            ID:             2,
            Title:          "HTMX’a Giriş",
            Excerpt:        "Partial render ve progressive enhancement…",
            ExcerptShort:   "Partial render ve progressive enhancement…",
            PublishedAt:    time.Now().AddDate(0, 0, -1),
            PublishedAtStr: time.Now().AddDate(0, 0, -1).Format("02 Jan 2006"),
        },
    }

    // Şablona gönderilecek veriyi hazırlıyoruz.
    data := map[string]any{
        "Title": "Yazılar",
        "Year":  time.Now().Year(),
        "Posts": posts,
    }

    // "posts" adlı şablonu (views/pages/posts.html) bu veriyle işliyoruz.
    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="Ara (dummy — HTMX sonraki yazıda)" />
    </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">Oku</span>
        </div>
      </a>
    </li>
    {{else}}
    <li class="card">
      <p class="italic text-neutral-600">Henüz yazı yok.</p>
    </li>
    {{end}}
  </ul>

  <div class="rounded-lg border border-blue-200 bg-blue-50 p-4">
    <strong class="block mb-1">Sonraki adım:</strong>
    Bu listeyi HTMX ile <em>partial</em> olarak yeniler hale getireceğiz.
  </div>
</section>
{{end}}

cmd/server/main.go

func render(w http.ResponseWriter, name string, data any) {

    // template.ParseFiles fonksiyonu, tüm şablon dosyalarını tek tek okuyup ayrıştırır.
    // Bu örnekte, her istekte şablonlar yeniden okunup ayrıştırılıyor.
    // Production ortamında bu işlemi sadece uygulama başlangıcında yapmak daha performanslıdır.
    tpl := template.Must(template.ParseFiles(
        "views/layouts/base.html",
        "views/partials/nav.html",
        "views/pages/"+name+".html",
    ))

    // ExecuteTemplate, ana (base) şablonu çağırır ve diğer şablonları (partial, content) onun içine yerleştirir.
    if err := tpl.ExecuteTemplate(w, "base", data); err != nil {
        // Herhangi bir hata oluşursa, sunucu hatası (500) döndürürüz.
        http.Error(w, err.Error(), 500)
    }
}

func main() {

    mux := http.NewServeMux()
    // /app.css isteğini, public klasöründeki dosyayı sunacak şekilde yönlendiriyoruz.
    mux.Handle("/app.css", http.FileServer(http.Dir("public")))

    // Router'ı oluştururken, yukarıdaki render fonksiyonunu parametre olarak veriyoruz.
    app := routerpkg.New(render)

    // Gelen tüm HTTP isteklerini (varsayılan "/" yolu üzerinden) oluşturduğumuz router'a yönlendiriyoruz.
    mux.Handle("/", app)
    log.Println("http://localhost:8081")
    _ = http.ListenAndServe("localhost:8081", mux)
}

🔒 Güvenlik & Anti-Pattern’ler

  • html/template otomatik escape yapar; kullanıcı verisini güvenle basarsın.

  • Asla doğrulanmamış HTML’i template.HTML ile “güvenli” yapma.

  • Unutma: escape, {{...}} çıktısında çalışır; JS eventlerinde inline string kaçışını da düzgün yap.

“HTML basmam lazım” diyorsan:

  • İçeriği back-office’te sanitize et (ör. allowlist).

  • Sonra template.HTML ver — ama gerçekten güvenli olduğundan emin ol.

🥊 Zorluklar vs Kolaylıklar

✅ Kolaylıklar

  • Basit yığın: Go + Template + (ileride) HTMX/Alpine. Build karmaşası yok.

  • SEO ve hız: SSR ile hızlı FCP, botlar mutlu.

  • Güvenlik: Oto-escape default güvenli.

⚠️ Zorluklar

  • Büyük front-end interaktivite: SPA konforunu birebir bekleme. (Çözüm: HTMX/Alpine parça parça.)

  • Template mirası: define/block isim çakışmaları, hangi dosyada hangi block override ediliyor — dikkat ister.

  • Önbellekleme & hot-reload: Dev’de her istekte parse rahat; prod’da parse-cache veya build-time embed istersin.

🚀 Bonus: Geliştirme Deneyimini İyileştirme (Vite + Air)

Hot-reload eksikliğini tek komutla çözmek mümkün.
İşin püf noktası:

  • Vite → TailwindCSS derlemesi + views klasöründeki .html dosyalarını izler

  • Air → Go kod değişikliklerini izler, otomatik yeniden başlatır

1️⃣ Air Kurulumu

go install github.com/cosmtrek/air@latest

.air.toml (örnek):

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

2️⃣ Vite Konfigürasyonu

vite.config.js

import { defineConfig } from 'vite'
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'

export default defineConfig({
  server: {
    watch: {
      // .html dosyalarını izleyelim
      paths: ['views/**/*.html']
    }
  },
  css: {
    postcss: {
      plugins: [tailwindcss, autoprefixer]
    }
  }
})

3️⃣ Makefile ile Tek Komut

dev:
\t# Vite + Air aynı anda çalışsın
\tconcurrently "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"
  }
}

🧭 html/template'i Tercih Etmek İçin Uygun Senaryolar

Statik ağırlıklı içerikler

  • Blog, haber, dokümantasyon gibi sayfa odaklı projelerde mükemmel çalışır.

  • Hızlı ilk yükleme, SEO dostu yapı.

Yönetim panelleri ve dashboard’lar

  • SSR ile hızlı tepki veren sayfalar üretilebilir.

  • Görsel tutarlılığı layout/partial yapısı sağlar. ✅ Basit formlar, CRUD işlemleri

  • Backend’de üretilen form yapıları için uygundur.

  • Validasyon sonrası sayfanın tamamını ya da bir kısmını kolayca güncelleyebilirsiniz.

⚠️ Ne Zaman Alternatif Düşünmeli?

Ağır client-side etkileşimler

  • Offline modlar

  • Karmaşık form sihirbazları

  • Çok adımlı state’li UI’ler

Bu tür senaryolarda SSR tabanlı başla, gerektiğinde HTMX veya Alpine.js gibi çözümlerle client tarafını güçlendir.

🚨 Sık Yapılan Hatalar

  • text/template ile HTML basmak → XSS riski.

  • Execute yerine ExecuteTemplate("base", ...) çağırmamak → layout çalışmaz.

  • Funcs()’ı Parse*’dan sonra çağırmak → helper’lar tanınmaz.

  • Aynı define adını iki farklı dosyada hatalı kullanmak → override karmaşası.

  • Template’te .Title beklerken Go tarafında Title vermemek → zero string ve sessiz hatalar.

❓ SSS

template.Must prod’da kalmalı mı?
Güvenli ama panik attırır. Prod’da template’leri uygulama açılışında parse etmek zaten mantıklı — parse hatası varsa servis ayağa kalkmasın.
Template’leri embed edebilir miyim?
Evet, Go embed ile binary içine gömebilirsin. Deploy sadeleşir.
Tailwind JIT prod’da ağır mı?
Build aşamasında minify eder ve kullanılmayan class’ları budar. Üretimde tek app.css ile gayet hafif.
HTML’i nasıl güvenle göstereceğim?
Admin onaylı/sanitize edilmiş içerikleri template.HTML olarak ver. Kullanıcı input’unu ASLA doğrudan bastırma.

🏁 Kapanış

Bu bölümde:

  • html/template ile base/partial mimarisini kurduk,

  • FuncMap ile helper yazdık,

  • “Blog Listesi” sayfası yaptık,

  • Güvenlik ve pratik tuzakları konuştuk.

🔜 Bir Sonraki Bölümde Ne Var?

Bölüm 2: HTMX ile Dinamik Parçalar

Sıradaki yazımızda, html/template sistemimizi HTMX ile zenginleştireceğiz.
Arama kutusu gibi bir input ile, sadece sayfanın bir bölümünü (örneğin liste alanını) güncelleyen interaktif yapılar kuracağız:

  • Sayfa yenilemeden içerik güncelleme

  • hx-get, hx-target, hx-swap gibi temel HTMX kavramları

  • Liste filtreleme örneği

  • Kodla açıklanan canlı demo parçaları

👉 Hiçbir framework ya da build sistemi kullanmadan, sadece HTML ve Go ile SPA benzeri deneyimler oluşturmanın yolunu göreceğiz.

📬 Kaynak Kodu

  • GitHub repo: https://github.com/uodev/go-htmx-blog
1
Subscribe to my newsletter

Read articles from Uygar Öztürk Ceylan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Uygar Öztürk Ceylan
Uygar Öztürk Ceylan