HTML Renderer Guide
This guide explains how the template rendering system works in Gojang, including template loading, caching, HTMX integration, and thread safety.
Overviewâ
The Gojang renderer handles:
- ðĻ Template parsing and caching
- ð HTMX partial rendering
- ð Thread-safe concurrent access
- ð Hot-reload in debug mode
- ðĶ Base template inheritance
Key Files:
app/gojang/views/renderers/renderer.go- Public site rendererapp/gojang/admin/admin_renderer.go- Admin panel rendererapp/views/templates/- Shared public templatesapp/<feature>/templates/- Feature-owned public templatesapp/gojang/admin/views/- Admin templates
How It Worksâ
1. Template Loading on Startupâ
When your app starts, the renderer walks the template directory and parses all .html files:
renderer, err := renderers.NewRenderer(debug)
// Automatically parses embedded app templates.
What happens:
- Finds all
.htmlfiles recursively - Identifies partials (files with
.partial.html) - Parses full pages with
base.htmlwrapper - Parses partials standalone (no wrapper)
- Stores templates in memory map
templates/
âââ base.html â Base layout (not cached directly)
âââ home.html â Parsed with base.html
âââ posts/
â âââ index.html â Parsed with base.html
â âââ list.partial.html â Parsed standalone
â âââ new.partial.html â Parsed standalone
2. Template Typesâ
Full Page Templatesâ
Standard pages that wrap in base.html:
<!-- posts/index.html -->
{{define "title"}}Posts{{end}}
{{define "content"}}
<h1>All Posts</h1>
<div id="posts-list">
<!-- Content here -->
</div>
{{end}}
Rendered as:
- Browser request â Full HTML with header, footer, navigation
- HTMX request â Just the
contentblock
Partial Templatesâ
Fragments for HTMX (end with .partial.html):
<!-- posts/list.partial.html -->
{{range .Data.Posts}}
<div class="post">
<h2>{{.Subject}}</h2>
<p>{{.Body}}</p>
</div>
{{end}}
Rendered as:
- Always just the fragment content
- No
base.htmlwrapper - Perfect for HTMX swaps
3. Rendering Flowâ
Standard Browser Requestâ
User â /posts â Handler
â
renderer.Render(w, r, "posts/index.html", data)
â
Template parsed with base.html
â
Full HTML page returned
Result: Complete page with header, nav, footer
HTMX Requestâ
User clicks button â hx-get="/posts/new"
â
Handler detects HX-Request header
â
renderer.Render(w, r, "posts/new.partial.html", data)
â
Just the partial content returned
â
HTMX swaps it into target element
Result: Only the requested fragment, no full page reload
4. Thread Safety with Mutexâ
Why Mutex is Neededâ
Go's HTTP server runs each request in its own goroutine (lightweight thread):
// Multiple requests happening simultaneously:
goroutine 1: Rendering home.html (reading templates)
goroutine 2: Rendering posts/list (reading templates)
goroutine 3: Debug mode hot-reload (writing templates)
Without synchronization â race condition â crash ðĨ
RWMutex (Read-Write Mutex)â
The renderer uses sync.RWMutex for efficient concurrent access:
type Renderer struct {
templates map[string]*template.Template
mu sync.RWMutex // Protects templates map
debug bool
}
Two types of locks:
| Lock Type | Usage | Behavior |
|---|---|---|
RLock() | Reading templates | Multiple reads can happen simultaneously |
Lock() | Writing templates | Exclusive access, blocks all reads/writes |
In Practiceâ
Reading (most common - 99% of operations):
r.mu.RLock() // Multiple goroutines can read in parallel
tmpl, ok := r.templates[name]
r.mu.RUnlock()
Writing (only in debug mode):
r.mu.Lock() // Exclusive lock - blocks everything
r.templates = newTemplates
r.mu.Unlock()
Why RWMutex vs Regular Mutex?â
| Scenario | Regular Mutex | RWMutex |
|---|---|---|
| 100 reads | Serialized (slow) | Parallel (fast âĄ) |
| 1 write during reads | Safe but slow | Safe and optimized |
| Typical workload | Overkill | Perfect fit â |
RWMutex is ideal because:
- Reads are frequent (every request)
- Writes are rare (only debug hot-reload)
- Reads don't conflict with each other
5. Debug Mode Hot-Reloadâ
How It Worksâ
func (r *Renderer) Render(w http.ResponseWriter, req *http.Request, name string, data *TemplateData) error {
// In debug mode, reload templates on every request
if r.debug {
tmpl, err := parseTemplates() // Re-parse from disk
if err == nil {
r.mu.Lock() // Exclusive lock
r.templates = tmpl // Replace cached templates
r.mu.Unlock()
}
}
// Continue rendering...
}
Benefits:
- â Edit HTML files and see changes immediately
- â No server restart needed
- â Great for development
Performance:
- ð Slower (parses files on every request)
- ðŦ Never use in production
Enabling Debug Modeâ
# Set in environment
export DEBUG=true
# Or in code
renderer, err := renderers.NewRenderer(true) // debug = true
6. Template Data Structureâ
TemplateData Fieldsâ
type TemplateData struct {
Title string // Page title
Data map[string]interface{} // Your custom data
User *models.User // Current authenticated user
CSRFToken string // CSRF protection token
IsHX bool // Is this an HTMX request?
Errors map[string]string // Form validation errors
CurrentPath string // Current URL path
Flash string // Flash message text
FlashType string // Flash type (success, error, info)
}
Usage Exampleâ
func (h *PostHandler) Index(w http.ResponseWriter, r *http.Request) {
posts, _ := h.Client.Post.Query().All(r.Context())
h.Renderer.Render(w, r, "posts/index.html", &renderers.TemplateData{
Title: "All Posts",
Data: map[string]interface{}{
"Posts": posts,
"Count": len(posts),
},
})
}
Automatic Fieldsâ
These are set automatically by the renderer:
// Automatically added - you don't set these
data.CSRFToken = nosurf.Token(req) // CSRF token
data.User = middleware.GetUser(req.Context()) // Current user
data.IsHX = req.Header.Get("HX-Request") == "true"
data.CurrentPath = req.URL.Path
7. HTMX Detectionâ
How the Renderer Detects HTMXâ
HTMX adds a header to all requests:
HX-Request: true
The renderer checks this header:
data.IsHX = req.Header.Get("HX-Request") == "true"
Smart Rendering Logicâ
// 1. HTMX request for a partial?
if data.IsHX && strings.Contains(name, ".partial.html") {
// Render just the partial
return tmpl.Execute(w, data)
}
// 2. HTMX request for full page?
if data.IsHX {
// Render just the "content" block (no base.html wrapper)
return tmpl.ExecuteTemplate(w, "content", data)
}
// 3. Regular browser request
// Render full page with base.html
return tmpl.ExecuteTemplate(w, "base.html", data)
Example Flowâ
// Browser visits /posts
// â Returns full HTML with header, nav, footer
// User clicks "New Post" button with hx-get="/posts/new"
// â Returns just the form partial (injected into modal)
// User clicks nav link with hx-boost="true"
// â Returns just the content block (swapped into main area)
8. Template Functionsâ
Custom functions available in all templates:
Built-in Functionsâ
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"lower": func(s string) string { return strings.ToLower(s) },
"contains": func(slice []string, item string) bool { ... },
}
Usage in Templatesâ
<!-- Math operations -->
<p>Total: {{add .Data.Price .Data.Tax}}</p>
<p>Page {{add .CurrentPage 1}} of {{.TotalPages}}</p>
<!-- String operations -->
<p>Email: {{lower .User.Email}}</p>
<!-- Conditionals -->
{{if contains .Data.Tags "featured"}}
<span class="badge">Featured</span>
{{end}}
9. Error Handlingâ
RenderError Helperâ
Built-in method for error pages:
func (r *Renderer) RenderError(w http.ResponseWriter, req *http.Request, status int, message string) {
w.WriteHeader(status)
data := &TemplateData{
Title: fmt.Sprintf("Error %d", status),
Data: map[string]interface{}{
"Status": status,
"Message": message,
},
}
_ = r.Render(w, req, "error.html", data)
}
Usageâ
// 404 Not Found
h.Renderer.RenderError(w, r, http.StatusNotFound, "Post not found")
// 500 Internal Server Error
h.Renderer.RenderError(w, r, http.StatusInternalServerError, "Database error")
// 403 Forbidden
h.Renderer.RenderError(w, r, http.StatusForbidden, "Access denied")
10. Complete Exampleâ
Handlerâ
func (h *PostHandler) Index(w http.ResponseWriter, r *http.Request) {
// Query posts from database
posts, err := h.Client.Post.Query().
WithAuthor().
Order(models.Desc(post.FieldCreatedAt)).
All(r.Context())
if err != nil {
h.Renderer.RenderError(w, r, http.StatusInternalServerError, "Failed to load posts")
return
}
// Render template
h.Renderer.Render(w, r, "posts/index.html", &renderers.TemplateData{
Title: "All Posts",
Data: map[string]interface{}{
"Posts": posts,
},
})
}
Full Page Templateâ
<!-- posts/index.html -->
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
<div class="container">
<div class="header-actions">
<h1>Posts</h1>
<button hx-get="/posts/new"
hx-target="#modal"
hx-swap="innerHTML"
class="btn btn-primary">
New Post
</button>
</div>
<div id="posts-list">
{{template "posts/list.partial.html" .}}
</div>
</div>
<div id="modal"></div>
{{end}}
Partial Templateâ
<!-- posts/list.partial.html -->
{{range .Data.Posts}}
<div class="card">
<h2>{{.Subject}}</h2>
<p>{{.Body}}</p>
<small>by {{.Edges.Author.Email}}</small>
<button hx-get="/posts/{{.ID}}/edit"
hx-target="#modal"
class="btn btn-sm">Edit</button>
</div>
{{else}}
<p class="text-muted">No posts yet.</p>
{{end}}
11. Performance Tipsâ
Production Setupâ
// â Don't do this in production
renderer, err := renderers.NewRenderer(true) // debug = true
// â
Production configuration
renderer, err := renderers.NewRenderer(false) // debug = false
Why?
- Debug mode parses templates on every request (slow)
- Production mode caches templates in memory (fast âĄ)
Template Cachingâ
First Request:
- Parse all templates from disk
- Store in memory map
- Serve request
Subsequent Requests (production):
- Read from memory cache (instant âĄ)
- No disk I/O
Subsequent Requests (debug):
- Re-parse from disk every time (slow ð)
- Good for development only
Benchmark Comparisonâ
| Mode | Request Time | Disk I/O |
|---|---|---|
| Production | ~100Ξs | None â |
| Debug | ~10ms | Every request â |
100x faster in production!
12. Admin Panel Rendererâ
The admin panel has its own renderer with slight differences:
Key Differencesâ
| Feature | Public Renderer | Admin Renderer |
|---|---|---|
| Base template | base.html | admin_base.html |
| Template dir | app/views/templates/ and feature templates/ folders | app/gojang/admin/views/ |
| Partial handling | Standard | Always fragments |
| Extra functions | Basic | fieldValue, getID, formatDateTime |
Admin-Specific Functionsâ
funcMap := template.FuncMap{
// Standard functions
"add", "sub", "mul", "div", "lower", "contains",
// Admin-specific
"fieldValue": extractFieldValue, // Get field from struct
"getID": getIDValue, // Get ID field
"formatDateTime": formatDateTimeField, // Format time fields
}
Common Patternsâ
Pattern 1: Modal Formsâ
// Handler returns partial
func (h *PostHandler) New(w http.ResponseWriter, r *http.Request) {
h.Renderer.Render(w, r, "posts/new.partial.html", nil)
}
<!-- Template with HTMX -->
<button hx-get="/posts/new" hx-target="#modal">New Post</button>
<div id="modal"></div>
Pattern 2: List Updatesâ
// Handler returns updated list
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
// Create post...
w.Header().Set("HX-Trigger", "closeModal")
w.Header().Set("HX-Retarget", "#posts-list")
h.Renderer.Render(w, r, "posts/list.partial.html", data)
}
Pattern 3: Form Validation Errorsâ
errors := forms.Validate(form)
if len(errors) > 0 {
h.Renderer.Render(w, r, "posts/new.partial.html", &renderers.TemplateData{
Errors: errors, // Show errors in form
})
return
}
Quick Referenceâ
Creating a New Pageâ
- Create template:
app/views/templates/mypage.html - Define blocks:
{{define "title"}}and{{define "content"}} - Create handler:
func (h *PageHandler) MyPage(w, r) { ... } - Render:
h.Renderer.Render(w, r, "mypage.html", data)
Creating a Partialâ
- Create template:
app/views/templates/mypartial.partial.html - No blocks needed - just raw HTML
- Create handler that renders it
- Use HTMX:
hx-get="/endpoint" hx-target="#somewhere"
Debug vs Productionâ
| Environment | Debug Mode | Result |
|---|---|---|
| Development | true | Hot-reload, slower |
| Production | false | Cached, faster ⥠|
Troubleshootingâ
Template Not Foundâ
Error: template mypage.html not found
Solutions:
- Check file exists in
app/views/templates/ - Check file name matches exactly (case-sensitive)
- Check file has
.htmlextension - Restart server to reload templates (if not in debug mode)
Partial Not Renderingâ
Button clicks but nothing happens
Solutions:
- Check partial ends with
.partial.html - Check HTMX attributes:
hx-get,hx-target,hx-swap - Check handler is registered in routes
- Check browser console for errors
- Verify HTMX is loaded (check Network tab)
Race Condition Errorsâ
fatal error: concurrent map read and map write
Solution:
- This means the mutex isn't being used properly
- Check all template map accesses use
RLock/RUnlockorLock/Unlock - File a bug report - this shouldn't happen!
Summaryâ
The Gojang renderer provides:
â
Template caching - Fast rendering in production
â
HTMX support - Smart partial vs full page detection
â
Thread safety - RWMutex for concurrent requests
â
Hot reload - Instant feedback during development
â
Base layouts - DRY template inheritance
â
Type safety - Structured data passing
Remember:
- Use
debug=falsein production - Partials end with
.partial.html - HTMX requests automatically get optimized responses
- Thread safety is handled automatically via RWMutex
Happy rendering! ðĻ