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/templateotomatik 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/templatemetin çıktıları için,html/templateise HTML çıktıları için kullanılır. HTML'de XSS koruması sağladığı için web projelerinde her zamanhtml/templatetercih edilir.define / block
Template miras yapısını kurmak için kullanılır.
defineile bir şablon bloğu tanımlanır,blockile bu blok başka bir dosyada override edilebilir. Örneğinbase.htmliçinde{{block "content" .}}...{{end}}varsa,home.htmlbu 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,fmtDategibi fonksiyonlarla veriyi biçimlendirebilirsin. Go tarafındatemplate.FuncMapile tanımlanır veParse*öncesinde şablona eklenir.Otomatik Escape (auto-escape)
html/templatekullanı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. Ancaktemplate.HTMLile 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
contentblock’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/templateotomatik escape yapar; kullanıcı verisini güvenle basarsın.Asla doğrulanmamış HTML’i
template.HTMLile “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.HTMLver — 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/blockisim ç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 +
viewsklasöründeki.htmldosyalarını izlerAir → 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/templateile HTML basmak → XSS riski.ExecuteyerineExecuteTemplate("base", ...)çağırmamak → layout çalışmaz.Funcs()’ıParse*’dan sonra çağırmak → helper’lar tanınmaz.Aynı
defineadını iki farklı dosyada hatalı kullanmak → override karmaşası.Template’te
.Titlebeklerken Go tarafındaTitlevermemek →zerostring ve sessiz hatalar.
❓ SSS
template.Must prod’da kalmalı mı?
Template’leri embed edebilir miyim?
embed ile binary içine gömebilirsin. Deploy sadeleşir.Tailwind JIT prod’da ağır mı?
app.css ile gayet hafif.HTML’i nasıl güvenle göstereceğim?
template.HTML olarak ver. Kullanıcı input’unu ASLA doğrudan bastırma.🏁 Kapanış
Bu bölümde:
html/templateile base/partial mimarisini kurduk,FuncMapile 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-swapgibi 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



