Ketika pengguna membuka https://example.com/articles/belajar-template, ada dua tempat halaman itu bisa dibangun: di browser dengan JavaScript setelah data diambil, atau di server yang mengirim HTML jadi. Cara kedua disebut server-side rendering (SSR) — browser tinggal menerima dan menampilkan markup yang sudah terisi data. Go mendukung pola ini penuh dari standard library: net/http untuk handler, html/template untuk render yang aman, tanpa framework tambahan. Pada chapter penutup section ini kita akan merakit blog kecil lengkap — repository in-memory, routing, partial header dan footer — lalu mengujinya lewat httptest tanpa membuka port jaringan sama sekali.
Arsitektur: Parse Sekali, Render Banyak #
Aturan penting SSR di Go: template diurai satu kali saat aplikasi dimulai, lalu dieksekusi berulang untuk setiap permintaan. Parsing itu pekerjaan berat — mesin membaca string, membangun pohon sintaks, dan (pada html/template) menganalisis konteks escaping — sementara eksekusinya ringan. Mem-parse ulang di dalam handler berarti pekerjaan berat itu diulang setiap request dan setiap kesalahan sintaks hanya muncul saat ada pengunjung.
Pola paling sederhana memakai variabel level package yang diinisialisasi template.Must: parsing terjadi saat package dimuat, dan template rusak langsung menggagalkan startup. Bila Anda ingin parsing tertunda hingga permintaan pertama — misalnya untuk mempercepat waktu start — bungkus dengan sync.Once agar parsing tetap terjadi tepat satu kali:
var (
sekali sync.Once
tmpl *template.Template
)
func templateSet() *template.Template {
sekali.Do(func() {
tmpl = template.Must(template.ParseFS(berkasTemplate, "layout/*.tmpl"))
})
return tmpl
}Program Utuh: Blog Kecil dengan httptest #
Berkas ssr-demo.go membangun aplikasi utuh: repository repoArtikel menyimpan dua artikel, http.ServeMux merutekan dua pola, dan template layout memakai partial header serta footer dari chapter komposisi. Fungsi main mensimulasikan tiga permintaan HTTP melalui httptest:
package main
import (
"bytes"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"strings"
)
// Artikel adalah entitas konten yang dirender menjadi halaman.
type Artikel struct {
Slug string
Judul string
Isi string
}
// repoArtikel adalah repository in-memory pengganti database.
type repoArtikel struct {
data []Artikel
}
// Semua mengembalikan seluruh artikel.
func (r repoArtikel) Semua() []Artikel {
return r.data
}
// Cari mencari satu artikel berdasarkan slug.
func (r repoArtikel) Cari(slug string) (Artikel, bool) {
for _, a := range r.data {
if a.Slug == slug {
return a, true
}
}
return Artikel{}, false
}
// tmpl diurai SEKALI saat package diinisialisasi. Kesalahan template
// harus menggagalkan startup lewat template.Must, bukan menghantui
// request pertama pengguna.
var tmpl = template.Must(template.New("layout").Parse(`
{{define "header"}}<!DOCTYPE html>
<html lang="id">
<head><meta charset="utf-8"><title>{{.Judul}} — Blog Contoh</title></head>
<body>
{{end}}{{define "footer"}}</body>
</html>
{{end}}{{define "list"}}{{template "header" .}}
<h1>{{.Judul}}</h1>
<ul>
{{range .Artikel}}<li><a href="/articles/{{.Slug}}">{{.Judul}}</a></li>
{{end}}
</ul>
{{template "footer" .}}
{{end}}{{define "show"}}{{template "header" .}}
<h1>{{.Judul}}</h1>
<p>{{.Isi}}</p>
<p><a href="/">kembali ke daftar</a></p>
{{template "footer" .}}
{{end}}
`))
// server menyatukan repository dan router.
type server struct {
repo repoArtikel
mux *http.ServeMux
}
func serverBaru(repo repoArtikel) *server {
s := &server{repo: repo, mux: http.NewServeMux()}
s.mux.HandleFunc("GET /{$}", s.handleDaftar)
s.mux.HandleFunc("GET /articles/{slug}", s.handleArtikel)
return s
}
// handleDaftar merender halaman indeks dari seluruh artikel.
func (s *server) handleDaftar(w http.ResponseWriter, r *http.Request) {
data := struct {
Judul string
Artikel []Artikel
}{
Judul: "Daftar Artikel",
Artikel: s.repo.Semua(),
}
renderHTML(w, "list", data)
}
// handleArtikel merender satu artikel berdasarkan path value.
func (s *server) handleArtikel(w http.ResponseWriter, r *http.Request) {
artikel, ok := s.repo.Cari(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
renderHTML(w, "show", artikel)
}
// renderHTML merender template bernama ke buffer sebelum dikirim;
// header 500 dikirim bila render gagal, tanpa output setengah jadi.
func renderHTML(w http.ResponseWriter, nama string, data any) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, nama, data); err != nil {
http.Error(w, "gagal merender halaman", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
func main() {
repo := repoArtikel{data: []Artikel{
{
Slug: "belajar-template",
Judul: "Belajar Template",
Isi: "Go menyediakan html/template untuk merender halaman dengan <b>auto-escape</b>.",
},
{
Slug: "server-side-rendering",
Judul: "Server-Side Rendering",
Isi: "Halaman dirender penuh di server lalu dikirim sebagai HTML jadi.",
},
}}
srv := serverBaru(repo)
// httptest mensimulasikan request tanpa jaringan nyata.
target := []struct{ metode, alamat string }{
{http.MethodGet, "/"},
{http.MethodGet, "/articles/belajar-template"},
{http.MethodGet, "/articles/tidak-ada"},
}
for i, t := range target {
if i > 0 {
fmt.Println()
}
req := httptest.NewRequest(t.metode, t.alamat, nil)
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
res := rec.Result()
fmt.Printf("%s %-28s status=%d content-type=%q\n",
t.metode, t.alamat, res.StatusCode, res.Header.Get("Content-Type"))
body := rec.Body.String()
fmt.Print(body)
if res.StatusCode == http.StatusOK {
fmt.Println("memuat data dari repository:", strings.Contains(body, "Belajar Template"))
}
}
}Output yang Dihasilkan:
GET / status=200 content-type="text/html; charset=utf-8"
<!DOCTYPE html>
<html lang="id">
<head><meta charset="utf-8"><title>Daftar Artikel — Blog Contoh</title></head>
<body>
<h1>Daftar Artikel</h1>
<ul>
<li><a href="/articles/belajar-template">Belajar Template</a></li>
<li><a href="/articles/server-side-rendering">Server-Side Rendering</a></li>
</ul>
</body>
</html>
memuat data dari repository: true
GET /articles/belajar-template status=200 content-type="text/html; charset=utf-8"
<!DOCTYPE html>
<html lang="id">
<head><meta charset="utf-8"><title>Belajar Template — Blog Contoh</title></head>
<body>
<h1>Belajar Template</h1>
<p>Go menyediakan html/template untuk merender halaman dengan <b>auto-escape</b>.</p>
<p><a href="/">kembali ke daftar</a></p>
</body>
</html>
memuat data dari repository: true
GET /articles/tidak-ada status=404 content-type="text/plain; charset=utf-8"
404 page not foundMari kita telaah baris demi baris program di atas:
- Baris 19-37 (
repoArtikel): repository in-memory menjadi pengganti database; handler tidak tahu dari mana data berasal sehingga pengujian tidak butuh PostgreSQL aktif. - Baris 42-62 (
var tmpl): seluruh halaman didefinisikan dalam satu set — partialheadermembuka dokumen HTML dan judul dinamis,footermenutupnya, sementaralistdanshowadalah dua halaman yang memanggil keduanya. - Baris 72-73 (
HandleFuncpola baru): routing memakai pola method-based dari Go 1.22 —GET /{$}mencocokkan hanya akar situs, sedangkanGET /articles/{slug}menangkap segmen dan mengeksposnya lewatr.PathValue("slug"). - Baris 90-97 (
handleArtikel): handler mencari artikel pada repository; bila tidak ditemukan,http.NotFoundmengirim respons 404 standar dan render dibatalkan. - Baris 101-109 (
renderHTML): eksekusi template ditampung dibytes.Bufferlebih dulu sehingga kegagalannya bisa dijawabhttp.Error500 tanpa mengirim output setengah jadi; headerContent-Type: text/html; charset=utf-8dipasang sebelumWrite— tanpa header ini server menebak jenis konten lewat deteksi otomatis, dan hasil tebakan tidak selalu sesuai harapan. - Baris 127-131 (daftar target): tiga permintaan uji dideklarasikan sebagai data — dua halaman valid dan satu slug yang sengaja tidak ada.
- Baris 132-149 (loop simulasi):
httptest.NewRequestmembuat*http.Requesttanpa koneksi nyata,httptest.NewRecordermenampung respons ke memori; status, header, dan body lalu diperiksa seperti respons sungguhan.
Membaca Hasil Uji #
Tiga baris pertama output membuktikan kontrak HTTP terpenuhi: halaman daftar dan artikel sama-sama berstatus 200 dengan content-type "text/html; charset=utf-8", sedangkan slug yang tidak ada menghasilkan 404 dengan text/plain bawaan http.NotFound. Baris memuat data dari repository: true berasal dari strings.Contains(body, "Belajar Template") — asersi sederhana bahwa data benar-benar sampai ke HTML yang dikirim.
Perhatikan pula baris isi artikel: teks dengan <b>auto-escape</b> terkirim sebagai <b>auto-escape</b>. Kombinasi SSR dan html/template bekerja otomatis — isi dari “database” tidak bisa menyelipkan markup ke halaman, persis seperti yang dibedah pada chapter Escaping dan Keamanan Template
.
Menjalankan Server Sungguhan #
Untuk melayani pengunjung nyata, gantikan simulasi httptest dengan server sungguhan — dua baris inilah yang diperlukan:
srv := serverBaru(repo)
log.Fatal(http.ListenAndServe(":8080", srv.mux))Handler, repository, dan template tidak perlu berubah; yang berganti hanya lapisan transport. Detail manajemen server produksi — graceful shutdown, timeout, middleware, hingga logging per permintaan — dibahas tuntas di section HTTP dan Networking , dan implementasi lengkapnya dipraktikkan di proyek Web Server .
Dari fmt.Println di program pertama hingga halaman HTML yang dirender per permintaan, perjalanan kita di Course Go telah menutup satu lingkaran penuh. Semua bekal di section ini — escaping sadar konteks, komposisi layout, fungsi kustom — kini tersusun menjadi aplikasi yang bisa dikunjungi orang lain, dan itu fondasi yang sama tempat aplikasi Go sungguhan berdiri.
Referensi #
- Dokumentasi package httptest
—
NewRequest,NewRecorder, dan server uji dalam memori. - Dokumentasi package html/template
— pemakaian template pada handler
net/http. - Dokumentasi package net/http
—
ServeMux, pola routing, danListenAndServe.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ssr-demo.go
Unduh berkaspackage main
import (
"bytes"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"strings"
)
// Artikel adalah entitas konten yang dirender menjadi halaman.
type Artikel struct {
Slug string
Judul string
Isi string
}
// repoArtikel adalah repository in-memory pengganti database.
type repoArtikel struct {
data []Artikel
}
// Semua mengembalikan seluruh artikel.
func (r repoArtikel) Semua() []Artikel {
return r.data
}
// Cari mencari satu artikel berdasarkan slug.
func (r repoArtikel) Cari(slug string) (Artikel, bool) {
for _, a := range r.data {
if a.Slug == slug {
return a, true
}
}
return Artikel{}, false
}
// tmpl diurai SEKALI saat package diinisialisasi. Kesalahan template
// harus menggagalkan startup lewat template.Must, bukan menghantui
// request pertama pengguna.
var tmpl = template.Must(template.New("layout").Parse(`
{{define "header"}}<!DOCTYPE html>
<html lang="id">
<head><meta charset="utf-8"><title>{{.Judul}} — Blog Contoh</title></head>
<body>
{{end}}{{define "footer"}}</body>
</html>
{{end}}{{define "list"}}{{template "header" .}}
<h1>{{.Judul}}</h1>
<ul>
{{range .Artikel}}<li><a href="/articles/{{.Slug}}">{{.Judul}}</a></li>
{{end}}
</ul>
{{template "footer" .}}
{{end}}{{define "show"}}{{template "header" .}}
<h1>{{.Judul}}</h1>
<p>{{.Isi}}</p>
<p><a href="/">kembali ke daftar</a></p>
{{template "footer" .}}
{{end}}
`))
// server menyatukan repository dan router.
type server struct {
repo repoArtikel
mux *http.ServeMux
}
func serverBaru(repo repoArtikel) *server {
s := &server{repo: repo, mux: http.NewServeMux()}
s.mux.HandleFunc("GET /{$}", s.handleDaftar)
s.mux.HandleFunc("GET /articles/{slug}", s.handleArtikel)
return s
}
// handleDaftar merender halaman indeks dari seluruh artikel.
func (s *server) handleDaftar(w http.ResponseWriter, r *http.Request) {
data := struct {
Judul string
Artikel []Artikel
}{
Judul: "Daftar Artikel",
Artikel: s.repo.Semua(),
}
renderHTML(w, "list", data)
}
// handleArtikel merender satu artikel berdasarkan path value.
func (s *server) handleArtikel(w http.ResponseWriter, r *http.Request) {
artikel, ok := s.repo.Cari(r.PathValue("slug"))
if !ok {
http.NotFound(w, r)
return
}
renderHTML(w, "show", artikel)
}
// renderHTML merender template bernama ke buffer sebelum dikirim;
// header 500 dikirim bila render gagal, tanpa output setengah jadi.
func renderHTML(w http.ResponseWriter, nama string, data any) {
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, nama, data); err != nil {
http.Error(w, "gagal merender halaman", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}
func main() {
repo := repoArtikel{data: []Artikel{
{
Slug: "belajar-template",
Judul: "Belajar Template",
Isi: "Go menyediakan html/template untuk merender halaman dengan <b>auto-escape</b>.",
},
{
Slug: "server-side-rendering",
Judul: "Server-Side Rendering",
Isi: "Halaman dirender penuh di server lalu dikirim sebagai HTML jadi.",
},
}}
srv := serverBaru(repo)
// httptest mensimulasikan request tanpa jaringan nyata.
target := []struct{ metode, alamat string }{
{http.MethodGet, "/"},
{http.MethodGet, "/articles/belajar-template"},
{http.MethodGet, "/articles/tidak-ada"},
}
for i, t := range target {
if i > 0 {
fmt.Println()
}
req := httptest.NewRequest(t.metode, t.alamat, nil)
rec := httptest.NewRecorder()
srv.mux.ServeHTTP(rec, req)
res := rec.Result()
fmt.Printf("%s %-28s status=%d content-type=%q\n",
t.metode, t.alamat, res.StatusCode, res.Header.Get("Content-Type"))
body := rec.Body.String()
fmt.Print(body)
if res.StatusCode == http.StatusOK {
fmt.Println("memuat data dari repository:", strings.Contains(body, "Belajar Template"))
}
}
}