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:
-
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.
-
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.
-
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.
-
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:
- HTML contains complete content; crawlers get all text on the first visit without waiting for JavaScript
- Short FCP (server returns complete HTML; browser renders immediately)
- Native social media preview support (Open Graph tags live in the HTML)
- Precise control over each page's
<title>and<meta description>
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" onclick="alert(1)">
<script>alert('xss')</script>
</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:
- SSR's SEO advantage is fundamental: content is available to crawlers on the first response, with no need to wait for JavaScript execution.
html/template's context-aware auto-escaping makes XSS virtually impossible — as long astemplate.HTMLis not used to bypass it.- Templates are parsed at startup and cached; they are concurrency-safe at runtime. Dev mode can disable caching for hot reload.
hreflangtags tell search engines about different language versions of the same content, preventing language versions from competing against each other in rankings.- JSON-LD structured data is a prerequisite for Google Rich Results and directly influences click-through rates.
- Core Web Vitals optimization is not just a frontend concern — the Go server makes meaningful contributions through TTFB optimization, cache headers, and Early Hints.