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:

go
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:

go
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:

text
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 &lt;b&gt;auto-escape&lt;/b&gt;.</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 found

Mari kita telaah baris demi baris program di atas:

  1. Baris 19-37 (repoArtikel): repository in-memory menjadi pengganti database; handler tidak tahu dari mana data berasal sehingga pengujian tidak butuh PostgreSQL aktif.
  2. Baris 42-62 (var tmpl): seluruh halaman didefinisikan dalam satu set — partial header membuka dokumen HTML dan judul dinamis, footer menutupnya, sementara list dan show adalah dua halaman yang memanggil keduanya.
  3. Baris 72-73 (HandleFunc pola baru): routing memakai pola method-based dari Go 1.22 — GET /{$} mencocokkan hanya akar situs, sedangkan GET /articles/{slug} menangkap segmen dan mengeksposnya lewat r.PathValue("slug").
  4. Baris 90-97 (handleArtikel): handler mencari artikel pada repository; bila tidak ditemukan, http.NotFound mengirim respons 404 standar dan render dibatalkan.
  5. Baris 101-109 (renderHTML): eksekusi template ditampung di bytes.Buffer lebih dulu sehingga kegagalannya bisa dijawab http.Error 500 tanpa mengirim output setengah jadi; header Content-Type: text/html; charset=utf-8 dipasang sebelum Write — tanpa header ini server menebak jenis konten lewat deteksi otomatis, dan hasil tebakan tidak selalu sesuai harapan.
  6. Baris 127-131 (daftar target): tiga permintaan uji dideklarasikan sebagai data — dua halaman valid dan satu slug yang sengaja tidak ada.
  7. Baris 132-149 (loop simulasi): httptest.NewRequest membuat *http.Request tanpa koneksi nyata, httptest.NewRecorder menampung 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 &lt;b&gt;auto-escape&lt;/b&gt;. 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:

go
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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ssr-demo.go

Unduh berkas
go
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"))
		}
	}
}