Chapter 28

Template Rendering and SEO

Template Rendering and SEO

In 2010, Google announced it had begun crawling JavaScript-rendered pages. The web development community rejoiced, and the SPA (Single-Page Application) era officially arrived.

In 2023, the bill came due.

An SEO engineer posted on Twitter: their client migrated a server-side rendered React application to a pure client-side SPA, and traffic dropped 40% over six months. Google's JavaScript rendering queue sometimes delays by days, and certain pages may never be indexed at all.

Server-side rendering (SSR) is experiencing a revival. Next.js, Nuxt.js, SvelteKit — every major frontend framework is adding SSR support, but at their core they are recreating what Go, Python, and PHP have done for 20 years: generate complete HTML on the server and send it directly to the browser.

And Go's html/template package was designed for exactly this purpose from day one.

Level 1 · What You Need to Know

SSR vs CSR: The Fundamental SEO Difference

CSR's SEO problem is not that "Google doesn't support JavaScript" — Google does, but with fundamental limitations:

  1. Two-phase crawling: Googlebot first fetches the HTML, then places pages requiring JavaScript rendering into a rendering queue. The delay can range from seconds to days. During this window, page content is invisible to Google.

  2. Rendering budget: Google has resource limits for rendering each website. Some pages on large sites may never be rendered, remaining permanently in a "crawled but not rendered" state.

  3. Third-party SEO tools: Bing, Baidu, and social media platforms (Twitter/Facebook/WeChat) typically don't execute JavaScript — they see only the empty HTML shell.

  4. Page speed: CSR pages show no content until JavaScript has downloaded and executed. Long FCP (First Contentful Paint) time harms Core Web Vitals scores, which indirectly affects rankings.

SSR's SEO advantages:

Go's SSR advantage over Node.js SSR (Next.js):

Go's SSR has one critical advantage: no JavaScript runtime overhead. html/template rendering is extremely fast (microseconds), keeping server latency low even under CPU-intensive rendering workloads. With Node.js SSR, V8's event loop and React's virtual DOM diffing actually become the bottleneck.

html/template's Security Model: Auto-Escaping

The core difference between Go's html/template and text/template is context-aware auto-escaping:

// text/template: raw output, unsafe
// html/template: automatic escaping, safe

import "html/template"

tmpl := template.Must(template.New("test").Parse(`
<div class="{{.Class}}">{{.Content}}</div>
<script>var name = "{{.Name}}";</script>
`))

data := struct {
    Class   string
    Content string
    Name    string
}{
    Class:   `evil" onclick="alert(1)`,
    Content: `<script>alert('xss')</script>`,
    Name:    `"; alert(1); //`,
}

html/template output:

<div class="evil&#34; onclick=&#34;alert(1)">
    &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;
</div>
<script>var name = "\"; alert(1); //";</script>

Notice: the same variable uses different escaping rules depending on context (HTML attribute, HTML text content, JavaScript string). This is not a simple global substitution — it is context-aware escaping.

html/template recognizes 6 contexts: HTML body, HTML attribute value, HTML attribute name, URL, CSS, and JavaScript. Each context has different escaping rules, making html/template nearly immune to XSS injection.

Intentional raw HTML output: Sometimes you genuinely need to output unescaped HTML (e.g., rich text content processed through a sanitizer). Go provides the template.HTML type:

// Dangerous: only use when the content is confirmed safe
safeHTML := template.HTML("<strong>Bold text</strong>")

Never convert user input directly to template.HTML — that bypasses all XSS protection.

Level 2 · How It Works

Template Inheritance: define, block, template

Go's template system implements inheritance and composition through three directives:

{{define "name"}}...{{end}}: Define a named template block (callable by other templates).

{{template "name" .}}: Invoke (embed) a named template block, passing . as data.

{{block "name" .}}...{{end}}: Equivalent to {{define "name"}}...{{end}}{{template "name" .}} — defines a block with a default value that child templates can override.

A real-world template directory structure:

templates/
├── layouts/
│   └── base.html    # base layout (HTML skeleton)
├── partials/
│   ├── header.html  # page header (navigation)
│   ├── footer.html  # page footer
│   └── meta.html    # SEO meta tags
└── pages/
    ├── index.html   # home page
    ├── about.html   # about page
    └── post.html    # article page

layouts/base.html:

{{define "base"}}
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    {{template "meta" .}}
    <title>{{block "title" .}}Default Title{{end}}</title>
    <link rel="stylesheet" href="/static/main.css">
</head>
<body>
    {{template "header" .}}
    <main>
        {{block "content" .}}{{end}}
    </main>
    {{template "footer" .}}
    <script src="/static/app.js" defer></script>
</body>
</html>
{{end}}

pages/post.html (inherits base layout and overrides blocks):

{{template "base" .}}

{{define "title"}}{{.Post.Title}} - YiteAI Blog{{end}}

{{define "content"}}
<article class="post">
    <h1>{{.Post.Title}}</h1>
    <time datetime="{{.Post.PublishedAt.Format "2006-01-02"}}">
        {{.Post.PublishedAt.Format "January 2, 2006"}}
    </time>
    <div class="post-body">
        {{.Post.HTMLContent}}
    </div>
</article>
{{end}}

Custom Template Functions

Go templates have limited built-in functions; real projects need custom function registrations:

package renderer

import (
    "fmt"
    "html/template"
    "strings"
    "time"
)

func TemplateFuncMap() template.FuncMap {
    return template.FuncMap{
        // Date formatting
        "formatDate": func(t time.Time, layout string) string {
            return t.Format(layout)
        },
        "timeAgo": func(t time.Time) string {
            d := time.Since(t)
            switch {
            case d < time.Minute:
                return "just now"
            case d < time.Hour:
                return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
            case d < 24*time.Hour:
                return fmt.Sprintf("%d hours ago", int(d.Hours()))
            default:
                return fmt.Sprintf("%d days ago", int(d.Hours()/24))
            }
        },
        // String operations
        "truncate": func(s string, n int) string {
            runes := []rune(s)
            if len(runes) <= n {
                return s
            }
            return string(runes[:n]) + "..."
        },
        "lower":   strings.ToLower,
        "upper":   strings.ToUpper,
        "replace": strings.ReplaceAll,
        // Safe HTML output (use with caution)
        "safeHTML": func(s string) template.HTML {
            return template.HTML(s)
        },
        // URL building
        "absURL": func(path string) string {
            return "https://example.com" + path
        },
        // Sequence generation (for pagination)
        "seq": func(n int) []int {
            result := make([]int, n)
            for i := range result {
                result[i] = i + 1
            }
            return result
        },
        // Comparison helpers
        "eq":  func(a, b interface{}) bool { return fmt.Sprint(a) == fmt.Sprint(b) },
        "ne":  func(a, b interface{}) bool { return fmt.Sprint(a) != fmt.Sprint(b) },
        "add": func(a, b int) int { return a + b },
        "sub": func(a, b int) int { return a - b },
    }
}

Template Caching

Go's html/template is thread-safe — a parsed template can be rendered concurrently. Production environments should parse all templates at startup and cache them:

package renderer

import (
    "bytes"
    "fmt"
    "html/template"
    "io"
    "path/filepath"
    "sync"
)

type TemplateCache struct {
    mu        sync.RWMutex
    templates map[string]*template.Template
    funcMap   template.FuncMap
    baseDir   string
}

func NewTemplateCache(baseDir string, funcMap template.FuncMap) *TemplateCache {
    return &TemplateCache{
        templates: make(map[string]*template.Template),
        funcMap:   funcMap,
        baseDir:   baseDir,
    }
}

// Load parses all templates and builds the cache
func (c *TemplateCache) Load() error {
    // Find all page templates
    pages, err := filepath.Glob(filepath.Join(c.baseDir, "pages", "*.html"))
    if err != nil {
        return err
    }
    
    for _, page := range pages {
        name := filepath.Base(page)
        
        // Each page template must be parsed together with layouts and partials
        files := []string{page}
        
        layouts, _ := filepath.Glob(filepath.Join(c.baseDir, "layouts", "*.html"))
        files = append(files, layouts...)
        
        partials, _ := filepath.Glob(filepath.Join(c.baseDir, "partials", "*.html"))
        files = append(files, partials...)
        
        // Parse the template set with the FuncMap
        tmpl, err := template.New(name).Funcs(c.funcMap).ParseFiles(files...)
        if err != nil {
            return fmt.Errorf("parsing template %s: %w", name, err)
        }
        
        c.mu.Lock()
        c.templates[name] = tmpl
        c.mu.Unlock()
    }
    
    return nil
}

func (c *TemplateCache) Render(w io.Writer, name string, data interface{}) error {
    c.mu.RLock()
    tmpl, ok := c.templates[name]
    c.mu.RUnlock()
    
    if !ok {
        return fmt.Errorf("template %s not found", name)
    }
    
    // Render to a buffer first; if rendering fails, no partial HTML is sent to the client
    var buf bytes.Buffer
    if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
        return fmt.Errorf("executing template %s: %w", name, err)
    }
    
    _, err := io.Copy(w, &buf)
    return err
}

Level 3 · Code in Practice

Building a Multi-Language Site

A multi-language site (i18n) requires handling at both the routing layer and the template layer:

package main

import (
    "net/http"
    "strings"
    
    "github.com/gin-gonic/gin"
)

// Supported languages
var supportedLangs = map[string]bool{
    "en": true,
    "zh": true,
    "ja": true,
}

// Language extraction middleware
func LangMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Priority: URL prefix > Cookie > Accept-Language header > default
        lang := extractLangFromPath(c)
        if lang == "" {
            lang = extractLangFromCookie(c)
        }
        if lang == "" {
            lang = extractLangFromHeader(c)
        }
        if lang == "" {
            lang = "en"  // default language
        }
        
        c.Set("lang", lang)
        c.Next()
    }
}

func extractLangFromPath(c *gin.Context) string {
    // Path format: /zh/about, /en/blog/...
    parts := strings.SplitN(c.Request.URL.Path, "/", 3)
    if len(parts) >= 2 && supportedLangs[parts[1]] {
        return parts[1]
    }
    return ""
}

func extractLangFromCookie(c *gin.Context) string {
    lang, err := c.Cookie("lang")
    if err != nil || !supportedLangs[lang] {
        return ""
    }
    return lang
}

func extractLangFromHeader(c *gin.Context) string {
    // Parse: Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
    header := c.GetHeader("Accept-Language")
    if header == "" {
        return ""
    }
    parts := strings.Split(header, ",")
    if len(parts) == 0 {
        return ""
    }
    // Extract the first language tag, strip quality value
    tag := strings.Split(strings.TrimSpace(parts[0]), ";")[0]
    // zh-CN → zh
    lang := strings.Split(tag, "-")[0]
    if supportedLangs[lang] {
        return lang
    }
    return ""
}

// i18n router configuration
func setupI18nRouter(cache *TemplateCache) *gin.Engine {
    r := gin.New()
    r.Use(gin.Recovery(), LangMiddleware())
    
    // Routes without language prefix (redirect to default language)
    r.GET("/", func(c *gin.Context) {
        lang := c.GetString("lang")
        c.Redirect(http.StatusFound, "/"+lang+"/")
    })
    
    // Routes with language prefix
    langGroup := r.Group("/:lang")
    langGroup.Use(func(c *gin.Context) {
        lang := c.Param("lang")
        if !supportedLangs[lang] {
            c.AbortWithStatus(http.StatusNotFound)
            return
        }
        c.Set("lang", lang)
        c.Next()
    })
    {
        langGroup.GET("/",           indexHandler(cache))
        langGroup.GET("/about",      aboutHandler(cache))
        langGroup.GET("/blog",       blogListHandler(cache))
        langGroup.GET("/blog/:slug", blogPostHandler(cache))
    }
    
    return r
}

func indexHandler(cache *TemplateCache) gin.HandlerFunc {
    return func(c *gin.Context) {
        lang := c.GetString("lang")
        
        data := gin.H{
            "Lang":         lang,
            "HreflangURLs": buildHreflangURLs(c.Request.URL.Path, lang),
            "CanonicalURL": buildCanonicalURL(c),
        }
        
        if err := cache.Render(c.Writer, "index.html", data); err != nil {
            c.Status(http.StatusInternalServerError)
        }
    }
}

type HreflangURL struct {
    Lang string
    URL  string
}

// Build the list of hreflang URLs for other language versions
func buildHreflangURLs(path string, currentLang string) []HreflangURL {
    result := make([]HreflangURL, 0, len(supportedLangs)+1)
    
    for lang := range supportedLangs {
        url := switchLangInPath(path, currentLang, lang)
        result = append(result, HreflangURL{
            Lang: lang,
            URL:  "https://example.com" + url,
        })
    }
    
    // x-default points to the English version
    result = append(result, HreflangURL{
        Lang: "x-default",
        URL:  "https://example.com" + switchLangInPath(path, currentLang, "en"),
    })
    
    return result
}

func switchLangInPath(path, fromLang, toLang string) string {
    if strings.HasPrefix(path, "/"+fromLang) {
        return "/" + toLang + path[len("/"+fromLang):]
    }
    return "/" + toLang + path
}

func buildCanonicalURL(c *gin.Context) string {
    return "https://example.com" + c.Request.URL.Path
}

In partials/meta.html, inject hreflang tags:

{{define "meta"}}
{{- range .HreflangURLs}}
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
{{- end}}
<link rel="canonical" href="{{.CanonicalURL}}">
<meta name="description" content="{{.Description}}">
{{end}}

Sitemap.xml Generation

A sitemap tells search engines what pages your site has, along with their priority and update frequency:

package sitemap

import (
    "encoding/xml"
    "net/http"
    "time"
    
    "github.com/gin-gonic/gin"
)

// XML structure definitions (following sitemap.org standard)
type URLSet struct {
    XMLName xml.Name `xml:"urlset"`
    XMLNS   string   `xml:"xmlns,attr"`
    URLs    []URL    `xml:"url"`
}

type URL struct {
    Loc        string  `xml:"loc"`
    LastMod    string  `xml:"lastmod,omitempty"`
    ChangeFreq string  `xml:"changefreq,omitempty"`
    Priority   float32 `xml:"priority,omitempty"`
}

// SitemapIndex for large sites (split when exceeding 50,000 URLs)
type SitemapIndex struct {
    XMLName  xml.Name       `xml:"sitemapindex"`
    XMLNS    string         `xml:"xmlns,attr"`
    Sitemaps []SitemapEntry `xml:"sitemap"`
}

type SitemapEntry struct {
    Loc     string `xml:"loc"`
    LastMod string `xml:"lastmod,omitempty"`
}

func SitemapHandler(db *Database) gin.HandlerFunc {
    return func(c *gin.Context) {
        baseURL := "https://example.com"
        
        urlset := URLSet{
            XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
        }
        
        // Static pages
        staticPages := []struct {
            path     string
            priority float32
            freq     string
        }{
            {"/",       1.0,  "daily"},
            {"/about",  0.8,  "monthly"},
            {"/blog",   0.9,  "daily"},
            {"/tools",  0.9,  "weekly"},
        }
        
        for _, lang := range []string{"en", "zh", "ja"} {
            for _, page := range staticPages {
                urlset.URLs = append(urlset.URLs, URL{
                    Loc:        baseURL + "/" + lang + page.path,
                    ChangeFreq: page.freq,
                    Priority:   page.priority,
                })
            }
        }
        
        // Dynamic content (fetched from database)
        posts, err := db.GetAllPublishedPosts()
        if err == nil {
            for _, post := range posts {
                for _, lang := range []string{"en", "zh"} {
                    if post.HasTranslation(lang) {
                        urlset.URLs = append(urlset.URLs, URL{
                            Loc:        baseURL + "/" + lang + "/blog/" + post.Slug,
                            LastMod:    post.UpdatedAt.Format("2006-01-02"),
                            ChangeFreq: "weekly",
                            Priority:   0.7,
                        })
                    }
                }
            }
        }
        
        // Output XML
        c.Header("Content-Type", "application/xml; charset=utf-8")
        c.Writer.WriteString(`<?xml version="1.0" encoding="UTF-8"?>` + "\n")
        
        encoder := xml.NewEncoder(c.Writer)
        encoder.Indent("", "  ")
        if err := encoder.Encode(urlset); err != nil {
            c.Status(http.StatusInternalServerError)
        }
    }
}

JSON-LD Structured Data

JSON-LD is Google's recommended format for structured data, telling search engines the semantic meaning of page content (this is an article, a book, a product...). It directly influences Rich Results in search:

package jsonld

import (
    "encoding/json"
    "html/template"
    "time"
)

// Article structure (following schema.org standard)
type Article struct {
    Context          string    `json:"@context"`
    Type             string    `json:"@type"`
    Headline         string    `json:"headline"`
    Description      string    `json:"description"`
    Author           Person    `json:"author"`
    Publisher        Org       `json:"publisher"`
    DatePublished    time.Time `json:"datePublished"`
    DateModified     time.Time `json:"dateModified"`
    MainEntityOfPage string    `json:"mainEntityOfPage"`
    Image            ImageObj  `json:"image"`
}

type Person struct {
    Type string `json:"@type"`
    Name string `json:"name"`
}

type Org struct {
    Type string  `json:"@type"`
    Name string  `json:"name"`
    Logo LogoObj `json:"logo"`
}

type LogoObj struct {
    Type string `json:"@type"`
    URL  string `json:"url"`
}

type ImageObj struct {
    Type   string `json:"@type"`
    URL    string `json:"url"`
    Width  int    `json:"width"`
    Height int    `json:"height"`
}

// Book structure
type Book struct {
    Context       string `json:"@context"`
    Type          string `json:"@type"`
    Name          string `json:"name"`
    Author        Person `json:"author"`
    Description   string `json:"description"`
    URL           string `json:"url"`
    ISBN          string `json:"isbn,omitempty"`
    InLanguage    string `json:"inLanguage"`
    NumberOfPages int    `json:"numberOfPages,omitempty"`
}

// ToScript converts a struct to a template.HTML-safe <script> tag content
func ToScript(v interface{}) (template.HTML, error) {
    data, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        return "", err
    }
    // This template.HTML is safe because json.Marshal escapes <, >, & characters
    return template.HTML(`<script type="application/ld+json">` + "\n" +
        string(data) + "\n" + `</script>`), nil
}

// BuildArticleSchema constructs structured data for a blog post
func BuildArticleSchema(post *Post, baseURL string) Article {
    return Article{
        Context:     "https://schema.org",
        Type:        "Article",
        Headline:    post.Title,
        Description: post.Description,
        Author: Person{
            Type: "Person",
            Name: post.AuthorName,
        },
        Publisher: Org{
            Type: "Organization",
            Name: "YiteAI",
            Logo: LogoObj{
                Type: "ImageObject",
                URL:  baseURL + "/static/logo.png",
            },
        },
        DatePublished:    post.PublishedAt,
        DateModified:     post.UpdatedAt,
        MainEntityOfPage: baseURL + "/blog/" + post.Slug,
        Image: ImageObj{
            Type:   "ImageObject",
            URL:    baseURL + post.CoverImage,
            Width:  1200,
            Height: 630,
        },
    }
}

// Open Graph tags structure
type OpenGraphTags struct {
    Title       string
    Description string
    Image       string
    URL         string
    Type        string
    SiteName    string
}

// Usage in a handler
func blogPostHandlerWithSEO(cache *TemplateCache) gin.HandlerFunc {
    return func(c *gin.Context) {
        post := getPost(c.Param("slug"))
        lang := c.GetString("lang")
        
        articleSchema, _ := ToScript(BuildArticleSchema(post, "https://example.com"))
        
        ogTags := OpenGraphTags{
            Title:       post.Title,
            Description: post.Description,
            Image:       "https://example.com" + post.CoverImage,
            URL:         "https://example.com/" + lang + "/blog/" + post.Slug,
            Type:        "article",
            SiteName:    "YiteAI",
        }
        
        data := gin.H{
            "Post":         post,
            "Lang":         lang,
            "JSONLDScript": articleSchema,
            "OGTags":       ogTags,
            "CanonicalURL": "https://example.com/" + lang + "/blog/" + post.Slug,
        }
        
        cache.Render(c.Writer, "post.html", data)
    }
}

In partials/meta.html:

{{define "meta"}}
{{- if .OGTags}}
<meta property="og:title" content="{{.OGTags.Title}}">
<meta property="og:description" content="{{.OGTags.Description}}">
<meta property="og:image" content="{{.OGTags.Image}}">
<meta property="og:url" content="{{.OGTags.URL}}">
<meta property="og:type" content="{{.OGTags.Type}}">
<meta property="og:site_name" content="{{.OGTags.SiteName}}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{.OGTags.Title}}">
<meta name="twitter:description" content="{{.OGTags.Description}}">
<meta name="twitter:image" content="{{.OGTags.Image}}">
{{- end}}
{{- if .JSONLDScript}}
{{.JSONLDScript}}
{{- end}}
{{end}}

Dynamic robots.txt Generation

robots.txt is typically a static file, but some scenarios require dynamic generation (e.g., different rules per domain, or crawl behavior controlled by environment variables):

func RobotsTxtHandler(sitemapURL string, env string) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("Content-Type", "text/plain; charset=utf-8")
        
        if env == "production" {
            // Production: allow crawling
            fmt.Fprintf(c.Writer, `User-agent: *
Allow: /

# Block API endpoints (conserve crawl budget; APIs have no SEO value)
Disallow: /api/
Disallow: /admin/

# Block query parameter pages (avoid duplicate content)
Disallow: /*?*

Sitemap: %s
`, sitemapURL)
        } else {
            // Non-production (staging/preview): block all crawling
            fmt.Fprintf(c.Writer, `User-agent: *
Disallow: /
`)
        }
    }
}

Level 4 · Deep Water

Template Hot Reload in Development

Production should cache all templates at startup, but frequent restarts during development are painful. You can implement hot reload for development mode:

package renderer

import (
    "bytes"
    "fmt"
    "html/template"
    "io"
    "os"
    "path/filepath"
    "sync"
)

type HotReloadRenderer struct {
    mu      sync.RWMutex
    cache   map[string]*template.Template
    baseDir string
    funcMap template.FuncMap
    devMode bool  // in dev mode, reload on every render
}

func (r *HotReloadRenderer) Render(w io.Writer, name string, data interface{}) error {
    if r.devMode {
        // Dev mode: re-parse templates every time (slower, but changes take effect immediately)
        return r.renderFresh(w, name, data)
    }
    
    // Production mode: use cache
    r.mu.RLock()
    tmpl, ok := r.cache[name]
    r.mu.RUnlock()
    
    if !ok {
        return fmt.Errorf("template %s not found", name)
    }
    
    return tmpl.ExecuteTemplate(w, "base", data)
}

func (r *HotReloadRenderer) renderFresh(w io.Writer, name string, data interface{}) error {
    // Re-read from disk every time
    pages, _ := filepath.Glob(filepath.Join(r.baseDir, "pages", name))
    layouts, _ := filepath.Glob(filepath.Join(r.baseDir, "layouts", "*.html"))
    partials, _ := filepath.Glob(filepath.Join(r.baseDir, "partials", "*.html"))
    
    files := append(pages, layouts...)
    files = append(files, partials...)
    
    tmpl, err := template.New(name).Funcs(r.funcMap).ParseFiles(files...)
    if err != nil {
        return err
    }
    
    var buf bytes.Buffer
    if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil {
        return err
    }
    
    _, err = io.Copy(w, &buf)
    return err
}

// Control mode via environment variable
func NewRenderer(baseDir string, funcMap template.FuncMap) *HotReloadRenderer {
    devMode := os.Getenv("GIN_MODE") != "release"
    r := &HotReloadRenderer{
        cache:   make(map[string]*template.Template),
        baseDir: baseDir,
        funcMap: funcMap,
        devMode: devMode,
    }
    if !devMode {
        r.Load()  // pre-load in production mode
    }
    return r
}

Caching Rendered Pages

For pages whose content rarely changes (home page, about page), you can cache the rendered HTML at the Go layer to avoid executing template rendering on every request:

package middleware

import (
    "bytes"
    "net/http"
    "sync"
    "time"

    "github.com/gin-gonic/gin"
)

type PageCache struct {
    mu      sync.RWMutex
    entries map[string]*cacheEntry
}

type cacheEntry struct {
    body        []byte
    contentType string
    statusCode  int
    cachedAt    time.Time
    ttl         time.Duration
}

func (e *cacheEntry) isExpired() bool {
    return time.Since(e.cachedAt) > e.ttl
}

var globalPageCache = &PageCache{
    entries: make(map[string]*cacheEntry),
}

// PageCacheMiddleware caches the rendered output of entire pages
func PageCacheMiddleware(ttl time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        // Only cache GET requests
        if c.Request.Method != http.MethodGet {
            c.Next()
            return
        }
        
        // Don't cache for authenticated users (content may be personalized)
        if c.GetString("user_id") != "" {
            c.Next()
            return
        }
        
        cacheKey := c.Request.URL.RequestURI()
        
        // Check cache
        globalPageCache.mu.RLock()
        entry, exists := globalPageCache.entries[cacheKey]
        globalPageCache.mu.RUnlock()
        
        if exists && !entry.isExpired() {
            c.Header("X-Cache", "HIT")
            c.Data(entry.statusCode, entry.contentType, entry.body)
            return
        }
        
        // Cache miss: execute rendering and capture output
        c.Header("X-Cache", "MISS")
        
        // Use a ResponseRecorder to capture handler output
        recorder := &responseRecorder{
            ResponseWriter: c.Writer,
            body:           &bytes.Buffer{},
            status:         http.StatusOK,
        }
        c.Writer = recorder
        c.Next()
        c.Writer = recorder.ResponseWriter  // restore
        
        // Only cache 200 responses
        if recorder.status == http.StatusOK {
            globalPageCache.mu.Lock()
            globalPageCache.entries[cacheKey] = &cacheEntry{
                body:        recorder.body.Bytes(),
                contentType: recorder.Header().Get("Content-Type"),
                statusCode:  recorder.status,
                cachedAt:    time.Now(),
                ttl:         ttl,
            }
            globalPageCache.mu.Unlock()
        }
    }
}

Core Web Vitals Optimization: Go's Contribution

Core Web Vitals (LCP, INP, CLS) are a Google ranking factor. The Go server can directly contribute in several ways:

1. Reducing TTFB (Time to First Byte)

// Use chunked transfer encoding (Chunked Transfer)
// Send the <head> immediately without waiting for the full page to render
func streamingRenderHandler(c *gin.Context) {
    // Early Hints (HTTP 103) for critical resources
    // Lets the browser start downloading CSS/fonts while the server is processing
    c.Header("Link", `</static/main.css>; rel=preload; as=style, </static/fonts.woff2>; rel=preload; as=font`)
    
    flusher, ok := c.Writer.(http.Flusher)
    if !ok {
        // Flushing not supported, render normally
        normalRender(c)
        return
    }
    
    // Immediately send the <head> (with CSS link)
    c.Writer.WriteHeader(http.StatusOK)
    c.Writer.Write([]byte(`<!DOCTYPE html><html><head>
<link rel="stylesheet" href="/static/main.css">
</head><body>`))
    flusher.Flush()  // push to client immediately; browser starts downloading CSS
    
    // Continue rendering the <body> (may require database queries)
    bodyContent := renderBodyContent(c)
    c.Writer.Write([]byte(bodyContent))
    c.Writer.Write([]byte(`</body></html>`))
}

2. Image Response Header Optimization

func imageHandler(c *gin.Context) {
    img := loadImage(c.Param("id"))
    
    // Set appropriate cache headers (affects repeat visit performance)
    c.Header("Cache-Control", "public, max-age=31536000, immutable")
    
    // Support conditional requests (304 Not Modified)
    etag := fmt.Sprintf(`"%x"`, md5.Sum(img.Data))
    c.Header("ETag", etag)
    
    if c.GetHeader("If-None-Match") == etag {
        c.Status(http.StatusNotModified)
        return
    }
    
    // Tell browsers the image dimensions (reduces layout shift CLS)
    c.Header("X-Image-Width", strconv.Itoa(img.Width))
    c.Header("X-Image-Height", strconv.Itoa(img.Height))
    
    c.Data(http.StatusOK, img.ContentType, img.Data)
}

3. Canonical URL Management

Canonical URLs prevent duplicate content issues (same content accessible at multiple URLs):

func canonicalURL(c *gin.Context) string {
    // Rules:
    // 1. Always use HTTPS
    // 2. Always use www (or never — be consistent)
    // 3. No trailing slash (except the home page)
    // 4. Strip tracking parameters (utm_source, etc.)
    
    path := c.Request.URL.Path
    
    // Remove trailing slash (except home page)
    if path != "/" && strings.HasSuffix(path, "/") {
        path = strings.TrimRight(path, "/")
    }
    
    return "https://www.example.com" + path
}

// Canonical redirect middleware
func CanonicalMiddleware(baseURL string) gin.HandlerFunc {
    return func(c *gin.Context) {
        host := c.Request.Host
        
        // If non-www access (and not localhost), redirect to www
        if host == "example.com" {
            target := "https://www." + host + c.Request.RequestURI
            c.Redirect(http.StatusMovedPermanently, target)
            return
        }
        
        // If HTTP access, redirect to HTTPS
        if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
            target := "https://" + host + c.Request.RequestURI
            c.Redirect(http.StatusMovedPermanently, target)
            return
        }
        
        c.Next()
    }
}

Key Takeaways:

  1. SSR's SEO advantage is fundamental: content is available to crawlers on the first response, with no need to wait for JavaScript execution.
  2. html/template's context-aware auto-escaping makes XSS virtually impossible — as long as template.HTML is not used to bypass it.
  3. Templates are parsed at startup and cached; they are concurrency-safe at runtime. Dev mode can disable caching for hot reload.
  4. hreflang tags tell search engines about different language versions of the same content, preventing language versions from competing against each other in rankings.
  5. JSON-LD structured data is a prerequisite for Google Rich Results and directly influences click-through rates.
  6. Core Web Vitals optimization is not just a frontend concern — the Go server makes meaningful contributions through TTFB optimization, cache headers, and Early Hints.
Rate this chapter
4.5  / 5  (4 ratings)

💬 Comments