On this page
Serve via net/httpEmbed into a binaryValidate at startupHot reloadMonitor engine metricsStructured logging (slog)Custom directiveCall Go methods from templatesMissing prop handlingStatic site with layoutSyntax highlightingServe custom element scriptsCustom elements in static buildTesting componentsHow-to Guides
Practical recipes for common tasks. Each guide assumes you have a working htmlc engine — see the overview for initial setup and the Go API reference for full API details.
Serve a component via net/http
You want to render htmlc components in response to HTTP requests using the standard library.
Use ServeComponent for partial HTML responses (HTMX, turbo frames) and ServePageComponent for full HTML pages. Both return an http.HandlerFunc you register on any *http.ServeMux.
package main import ( "log" "net/http" "github.com/dhamidi/htmlc" ) func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", }) if err != nil { log.Fatal(err) } mux := http.NewServeMux() // Fragment handler — no <html> wrapper, good for HTMX responses. // The data function is called once per request. mux.HandleFunc("GET /search", engine.ServeComponent( "SearchResults", func(r *http.Request) map[string]any { return map[string]any{"query": r.URL.Query().Get("q")} }, )) // Full-page handler — injects <style> into <head> automatically. // Return both the data map and the HTTP status code. mux.HandleFunc("GET /post/{id}", engine.ServePageComponent( "PostPage", func(r *http.Request) (map[string]any, int) { post, err := db.GetPost(r.PathValue("id")) if err != nil { return map[string]any{"error": err.Error()}, http.StatusNotFound } return map[string]any{"post": post}, http.StatusOK }, )) log.Fatal(http.ListenAndServe(":8080", mux)) }
Pass per-request data (current user, CSRF token, feature flags) to every handler at once with WithDataMiddleware instead of repeating the logic in each data function.
Embed components into a Go binary
You want to ship a self-contained binary that has no dependency on files being present at the deployment path.
Use //go:embed to bundle the components/ directory into the binary, then pass the resulting embed.FS as Options.FS. When FS is set, all directory walks and file reads use it instead of the OS filesystem.
package main import ( "embed" "log" "net/http" "github.com/dhamidi/htmlc" ) //go:embed components var componentsFS embed.FS func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "components", // path inside the embedded FS FS: componentsFS, }) if err != nil { log.Fatal(err) } mux := http.NewServeMux() engine.Mount(mux, map[string]string{ "GET /{$}": "HomePage", "GET /about": "AboutPage", }) log.Fatal(http.ListenAndServe(":8080", mux)) }
Expected directory layout:
myapp/ ├── main.go └── components/ ├── Layout.vue ├── HomePage.vue └── AboutPage.vue
This is recommended for production deployments. Without FS, the engine reads from the OS filesystem and the components/ directory must exist at the working directory of the running process.
Use hot-reload during development
You want component changes to be reflected in the browser without restarting the server.
Set Options.Reload = true. The engine will stat every registered file before each render and re-parse any that have changed.
engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", Reload: true, })
Tradeoff:Reload adds a stat syscall per component file on every render. Leave it false in production. A common pattern is to gate it behind a flag:
import "flag" var dev = flag.Bool("dev", false, "enable hot reload") func main() { flag.Parse() engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", Reload: *dev, }) // ... }
Run with go run . -dev locally and without the flag in production. Alternatively, use a build tag to set the constant at compile time so the production binary has zero overhead.
Monitor engine metrics with expvars
You want to expose htmlc engine metrics at the standard Go /debug/vars endpoint for dashboards and health checks.
Go's built-in expvar package publishes named variables at /debug/vars as a JSON object. Calling engine.PublishExpvars(prefix) registers counters and configuration state under that prefix so any monitoring tool that can read /debug/vars can observe the engine.
Step 1 — Create the engine and publish metrics
Call PublishExpvars once at startup, before serving any requests. A blank import of expvar registers the /debug/vars handler on http.DefaultServeMux automatically:
package main import ( "log" "net/http" _ "expvar" // registers /debug/vars on http.DefaultServeMux "github.com/dhamidi/htmlc" ) func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", }) if err != nil { log.Fatal(err) } // Register all engine metrics under "myapp" in /debug/vars. engine.PublishExpvars("myapp") mux := http.NewServeMux() mux.HandleFunc("GET /{$}", engine.ServePageComponent("HomePage", nil)) // For a custom mux, add the expvar handler explicitly: // mux.Handle("GET /debug/vars", expvar.Handler()) log.Fatal(http.ListenAndServe(":8080", mux)) }
Step 2 — Inspect the output
With the server running, fetch the metrics and pipe through jq to extract the engine block:
curl -s http://localhost:8080/debug/vars | jq '.myapp'
Example output:
{ "reload": 0, "debug": 0, "componentDir": "./components", "fs": "<nil>", "renders": 42, "renderErrors": 0, "reloads": 2, "renderNanos": 125432100, "components": 15, "info": { "directives": ["myCustom"] } }
Reading the counters
Use the raw counters to compute derived metrics:
- Error rate:
renderErrors / renders - Average render latency:
renderNanos / rendersnanoseconds per render - Reload activity:
reloadsshould only increment during development; a non-zero value in production means hot-reload is accidentally enabled
Live option toggling
The setter methods (SetReload, SetDebug, SetComponentDir, SetFS) update both the live engine option and the corresponding expvar immediately. For example, enabling hot-reload at runtime without restarting the process:
engine.SetReload(true) // curl -s .../debug/vars | jq '.myapp.reload' → 1
PublishExpvars twice with the same prefix panics. Register metrics exactly once per engine per process, immediately after creating the engine.Add structured logging with slog
Produce one structured log record per rendered component so you can identify slow or unexpectedly large components in production.
Minimal setup
Pass slog.Default() as Options.Logger to start receiving one log record per component on every render:
package main import ( "log" "log/slog" "net/http" "github.com/dhamidi/htmlc" ) func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", Logger: slog.Default(), }) if err != nil { log.Fatal(err) } mux := http.NewServeMux() mux.HandleFunc("GET /{$}", engine.ServePageComponent("HomePage", nil)) log.Fatal(http.ListenAndServe(":8080", mux)) }
Records are emitted at slog.LevelDebug. With the default text handler you will see one line per component, leaf-first, ending with the root page component.
Using a custom handler for machine-readable output
For log aggregators (Datadog, Loki, Cloud Logging) create a JSON handler writing to os.Stdout:
package main import ( "log" "log/slog" "net/http" "os" "github.com/dhamidi/htmlc" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, })) engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", Logger: logger, }) if err != nil { log.Fatal(err) } mux := http.NewServeMux() mux.HandleFunc("GET /{$}", engine.ServePageComponent("HomePage", nil)) log.Fatal(http.ListenAndServe(":8080", mux)) }
Each component emits a record like:
{"time":"2026-03-16T12:00:00.001Z","level":"DEBUG","msg":"component rendered","component":"NavLink","duration":1200000,"bytes":142}
Note: duration is nanoseconds as int64 in JSON — this is standard slog behaviour for time.Duration values.
Request-scoped logging
Attach request metadata (such as a trace or request ID) using logger.With(...) and pass the enriched logger to a per-request renderer via WithLogger:
func makeHandler(baseLogger *slog.Logger, component *htmlc.Component) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { requestID := r.Header.Get("X-Request-ID") logger := baseLogger.With("request_id", requestID) renderer := htmlc.NewRenderer(component).WithLogger(logger) // ... use renderer } }
Filtering noise in development
All component records are emitted at slog.LevelDebug. To silence them where debug output is unwanted, set HandlerOptions.Level to slog.LevelInfo:
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, // component records at LevelDebug are suppressed }))
Interpreting the output
Each log record contains four attributes:
component— the resolved component name (e.g.NavBar).duration— wall-clock time for the component subtree. In text format this appears as1.2ms; in JSON it is nanoseconds asint64.bytes— bytes written by the component subtree.error— present only onLevelErrorrecords for failed renders.
Records appear leaf-first (post-order traversal): child components are logged before their parents. The root page component is always the last record in the batch.
Using the constants in tests and alerting
Use htmlc.MsgComponentRendered and htmlc.MsgComponentFailed instead of hard-coding strings when writing log-based test assertions or alerting rules:
// In a test using a log/slog capture handler: if record.Message != htmlc.MsgComponentRendered { t.Errorf("unexpected log message: %q", record.Message) } // In an alerting rule (pseudo-code): // alert when msg == htmlc.MsgComponentFailed
Write a custom directive
You want to add a reusable HTML attribute behaviour that is not covered by the built-in directives.
Implement the htmlc.Directive interface and register it via Options.Directives or Engine.RegisterDirective. The interface has two hooks — Created (before rendering) and Mounted (after rendering). Both receive the working node, the binding, and a context.
Example: a v-uppercase directive that uppercases all direct text children of the element.
package main import ( "io" "strings" "golang.org/x/net/html" "github.com/dhamidi/htmlc" ) type UppercaseDirective struct{} // Created is called before the element is rendered. // Mutate node.Attr or child text nodes here. func (d *UppercaseDirective) Created( node *html.Node, binding htmlc.DirectiveBinding, ctx htmlc.DirectiveContext, ) error { for c := node.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.TextNode { c.Data = strings.ToUpper(c.Data) } } return nil } // Mounted is called after the element's closing tag is written to w. // Bytes written to w appear immediately after the element in the output. func (d *UppercaseDirective) Mounted( w io.Writer, node *html.Node, binding htmlc.DirectiveBinding, ctx htmlc.DirectiveContext, ) error { return nil } func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", Directives: htmlc.DirectiveRegistry{ "uppercase": &UppercaseDirective{}, }, }) // ... }
Use in a template:
<p v-uppercase>hello world</p> <!-- renders: <p>HELLO WORLD</p> -->
See DirectiveBinding and DirectiveContext in the Go API reference for the full set of fields available to directive implementations.
Call Go methods from templates
You want to call methods on Go values already in the render scope, without registering each one individually as a function.
When to use this instead of RegisterFunc
Use bound methods when the behaviour is naturally attached to a type already in scope — models, routers, formatters. Use RegisterFunc for standalone helper functions that are independent of any scope value (e.g., formatDate, truncate).
Zero-argument accessor — step by step
1. Define a Go struct with an exported method:
type Post struct { Title string Body string } func (p Post) Summary() string { if len(p.Body) < 100 { return p.Body } return p.Body[:100] }
2. Pass an instance into the render scope under a key:
ctx := context.Background() html, err := engine.RenderPage(ctx, "ArticlePage", map[string]any{ "post": Post{Title: "Hello", Body: "..."}, })
3. Access it from the template with dot notation — no parentheses required:
<p>{{ post.Summary }}</p> <!-- or with explicit parentheses: --> <p>{{ post.Summary() }}</p>
Method with arguments
Methods with parameters use normal call syntax. Define the type and register it in scope:
type Router struct{ routes map[string]string } func (r *Router) LinkFor(route string) string { return r.routes[route] } // In handler: html, err := engine.RenderPage(ctx, "Nav", map[string]any{ "router": &Router{routes: map[string]string{"home": "/"}}, })
Template call:
<a :href="router.LinkFor('home')">Home</a>
Error-returning methods
Methods with the signature func (T) Method() (string, error) are fully supported. A non-nil error stops rendering and surfaces as a *RenderError. For example, a currency formatter that validates its input:
type Money struct{ Amount float64; Currency string } func (m Money) Format() (string, error) { if m.Currency == "" { return "", fmt.Errorf("Format: currency is required") } return fmt.Sprintf("%.2f %s", m.Amount, m.Currency), nil }
<span>{{ price.Format() }}</span>
If Currency is empty the render aborts with the error from Format.
- Unexported methods are not accessible from templates.
- The method must be exported (uppercase first letter) on a concrete or pointer receiver.
- Interface values work if the underlying concrete type exposes the method.
Handle missing props gracefully
You want to control what happens when a template references a variable that was not passed as a prop.
By default, a missing prop renders a visible [missing: <name>] placeholder in the HTML. Use WithMissingPropHandler to choose a different behaviour.
// Abort the render and return an error — recommended for production. // Any missing prop causes the entire response to fail, making omissions visible // during development and CI rather than in rendered HTML. engine.WithMissingPropHandler(htmlc.ErrorOnMissingProp) // Render a visible placeholder string "MISSING PROP: <name>". // Useful when gradually migrating templates that have optional props. engine.WithMissingPropHandler(htmlc.SubstituteMissingProp)
Both are package-level functions with the MissingPropFunc signature — you can write your own to log, metric-count, or substitute a default value:
engine.WithMissingPropHandler(func(name string) (any, error) { slog.Warn("missing prop", "name", name) return "", nil // silently substitute empty string })
Validate all components at startup
You want to catch broken component references before the server starts serving traffic.
Call ValidateAll after creating the engine. It checks every registered component for child component references that cannot be resolved and returns one ValidationError per problem. An empty slice means all components are valid.
package main import ( "log" "net/http" "os" "github.com/dhamidi/htmlc" ) func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", }) if err != nil { log.Fatal(err) } // Surface missing-component errors before accepting any traffic. if errs := engine.ValidateAll(); len(errs) != 0 { for _, e := range errs { log.Println(e) } os.Exit(1) } mux := http.NewServeMux() // ... register routes ... log.Fatal(http.ListenAndServe(":8080", mux)) }
Run ValidateAll in CI by building a small cmd/validate/main.go that calls it and exits non-zero on any error. This catches typos in component names at review time rather than at runtime.
Build a static site with layout wrapping
You want to generate static HTML files where every page shares a common layout component.
Using the CLI
Pass -layout to htmlc build. The named component is used as the outer wrapper for every page in the -pages directory.
htmlc build \ -dir ./components \ -pages ./pages \ -out ./dist \ -layout Layout
Each page component receives a slot prop containing the rendered inner page HTML. The layout component must render {{ slot }} (or use v-html="slot") where the page content should appear. See the CLI reference for all flags.
Using the Go API
Call RenderFragment for the inner page, then pass the result as data to RenderPage on the layout:
// Render the inner page as a fragment (no full <html> document). inner, err := engine.RenderFragmentString(context.Background(), "BlogPost", map[string]any{ "title": post.Title, "content": post.Body, }) if err != nil { return err } // Wrap the fragment in the layout, which renders a full HTML document. // The layout template uses {{ slot }} to embed the inner HTML. html, err := engine.RenderPageString(context.Background(), "Layout", map[string]any{ "pageTitle": post.Title, "slot": inner, }) if err != nil { return err } // Write html to a file or http.ResponseWriter.
This approach gives you full control over which pages receive which layout and what data is passed to each layer.
Add syntax highlighting with an external directive
You want source code blocks in your static site to be syntax-highlighted at build time using htmlc build.
v-syntax-highlight is a ready-made external directive that wraps the Chroma library. Place it in your component directory and htmlc build picks it up automatically.
Prerequisites:htmlc build is working for your project and Go 1.22+ is installed.
Step 1 — Install the directive
go install github.com/dhamidi/htmlc/cmd/v-syntax-highlight@latest
Then copy the binary into your component directory (the -dir you pass to htmlc build):
cp "$(go env GOPATH)/bin/v-syntax-highlight" ./components/
Step 2 — Generate a stylesheet
The directive uses CSS classes emitted by Chroma. Generate a stylesheet for the monokai theme (or any other Chroma style) and save it to your public assets directory:
v-syntax-highlight -print-css -style monokai > public/highlight.css
Link the stylesheet in your layout component:
<link rel="stylesheet" href="/highlight.css">
Step 3 — Mark code blocks in templates
Add v-syntax-highlight="'<language>'" to any <code> or <pre> element. The directive replaces the element's content with highlighted HTML and adds a language-* class:
<pre><code v-syntax-highlight="'go'">package main import "fmt" func main() { fmt.Println("hello, world") } </code></pre>
Step 4 — Build
htmlc build -dir ./components -pages ./pages -out ./dist
The generated HTML will contain highlighted <span> elements styled by the Chroma CSS classes. See the external directives reference for the full protocol and discovery rules.
Serve custom element scripts from a Go server
You want to write a component with a <script customelement> block and have its JavaScript served automatically from your Go HTTP server.
Step 1 — Write a custom element component
Create a component with both a <template> block (for server-rendered HTML) and a <script customelement> block (for client-side interactivity). The tag name is derived from the component's directory path and file name.
<!-- components/ui/Counter.vue --> <template> <div class="counter"> <span>{{ initial }}</span> </div> </template> <script customelement> class UiCounter extends HTMLElement { connectedCallback() { const span = this.querySelector('span') let n = parseInt(span.textContent, 10) this.addEventListener('click', () => span.textContent = ++n) } } customElements.define('ui-counter', UiCounter) </script>
Step 2 — Mount the script handler
Call engine.ScriptHandler() and register it at a path prefix on your mux. Browsers will fetch script files from this prefix.
engine, err := htmlc.New(htmlc.Options{ComponentDir: "./components"}) if err != nil { log.Fatal(err) } http.Handle("/scripts/", http.StripPrefix("/scripts/", engine.ScriptHandler()))
Step 3 — Add importMap() to your page <head>
The importMap() template function emits an import map<script> tag that tells the browser where to find each custom element module. Add it to every layout or page template that may use custom element components:
<head> <meta charset="utf-8"> {{ importMap() }} </head>
Step 4 — Use the component in a page
Reference the component as usual. It renders as a custom element tag wrapping the server-rendered template output:
<UiCounter :initial="5"></UiCounter> <!-- renders: <ui-counter><div class="counter"><span>5</span></div></ui-counter> -->
Note:importMap() emits nothing when no custom element components are present in the rendered page, so it is safe to include unconditionally in layouts.
See the Custom Elements reference for the full API.
Include custom element scripts in a static build
You want your htmlc build output to include the JavaScript for custom element components alongside the HTML pages.
Step 1 — Write your custom element components
Create components with <script customelement> blocks as shown in the guide above, or see the Custom Elements reference.
Step 2 — Add importMap() to your page <head>
The static build uses the same importMap() function as the server — include it in your layout or page template <head>:
<head> <meta charset="utf-8"> {{ importMap() }} </head>
Step 3 — Run htmlc build
No additional flags are needed. The build command detects custom element components automatically:
htmlc build -dir ./components -pages ./pages -out ./dist
Step 4 — Inspect the output
The output directory will contain a scripts/ subdirectory with one hashed file per unique custom element script and an index.js entry point that imports them all:
dist/ index.html about.html scripts/ a1b2c3d4e5f6a7b8.js ← one file per unique custom element script index.js ← ES module entry point that imports all scripts
Step 5 — Serve the scripts directory
Configure your web server to serve the scripts/ directory so browsers can fetch the script files. The import map emitted by importMap() already points to the correct paths.
Note: The scripts/ directory is only created when at least one custom element component is used. Projects without <script customelement> blocks produce no scripts/ directory.
See the Custom Elements reference for the full API.
Testing components
Write unit tests for .vue components without touching the filesystem.
The htmlctest package provides a fluent harness for testing htmlc components using an in-memory filesystem and a DOM-query API. Add it to your module:
go get github.com/dhamidi/htmlc/htmlctest
Quick start — Build shorthand
Build wraps a template snippet in <template>…</template>, registers it as a component named Root, and returns a *Harness ready to render. Chain Fragment → Find → assertion:
func TestGreeting(t *testing.T) { htmlctest.Build(t, `<p class="greeting">Hello {{ name }}!</p>`). Fragment("Root", map[string]any{"name": "World"}). Find(htmlctest.ByTag("p").WithClass("greeting")). AssertText("Hello World!") }
Multiple components — NewHarness
When the component under test references child components, register all required .vue files with NewHarness:
func TestCard(t *testing.T) { h := htmlctest.NewHarness(t, map[string]string{ "Badge.vue": `<template><span class="badge">{{ label }}</span></template>`, "Card.vue": `<template> <div class="card"> <h2>{{ title }}</h2> <Badge :label="status" /> </div> </template>`, }) h.Fragment("Card", map[string]any{ "title": "Order #42", "status": "shipped", }). Find(htmlctest.ByTag("h2")).AssertText("Order #42"). Find(htmlctest.ByClass("badge")).AssertText("shipped") }
Assertion methods
All Assert* methods call t.Fatalf on failure and return the receiver for chaining:
| Method | Checks |
|---|---|
r.AssertHTML(want) | Exact HTML after whitespace normalisation; reports tree diff on mismatch |
r.Find(query) | Returns a Selection of all matching nodes |
s.AssertExists() | At least one node matched |
s.AssertNotExists() | No nodes matched |
s.AssertCount(n) | Exactly n nodes matched |
s.AssertText(text) | Normalised text of the first matched node |
s.AssertAttr(attr, value) | Named attribute value of the first matched node |
Query constructors and combinators
Queries are immutable. Build and refine them with constructors and combinators:
// Match <li class="active" data-id="1"> inside a <ul> htmlctest.ByTag("li"). WithClass("active"). WithAttr("data-id", "1"). Descendant(htmlctest.ByTag("ul"))
| Constructor / combinator | Matches |
|---|---|
ByTag("div") | Elements by tag name (case-insensitive) |
ByClass("active") | Elements that have the given CSS class |
ByAttr("data-id", "42") | Elements where data-id="42" |
q.WithClass(class) | Also requires the given class |
q.WithAttr(attr, value) | Also requires the given attribute |
q.Descendant(ancestor) | Matched element must be inside ancestor |
Testing v-for output
func TestList(t *testing.T) { htmlctest.Build(t, ` <ul> <li v-for="item in items">{{ item }}</li> </ul> `). Fragment("Root", map[string]any{ "items": []string{"alpha", "beta", "gamma"}, }). Find(htmlctest.ByTag("li")). AssertCount(3) }
Testing conditional rendering
func TestBadge_Hidden(t *testing.T) { htmlctest.Build(t, `<span v-if="show" class="badge">NEW</span>`). Fragment("Root", map[string]any{"show": false}). Find(htmlctest.ByClass("badge")). AssertNotExists() }
Testing with custom directives
Pass an htmlc.Options with a Directives map to NewHarness to test components that use custom directives:
h := htmlctest.NewHarness(t, map[string]string{ "Page.vue": `<template><pre v-upper="text"></pre></template>`, }, htmlc.Options{ Directives: htmlc.DirectiveRegistry{ "upper": &UpperDirective{}, }, })