On this page
1 — Install htmlc2 — Write a component3 — Create an engine4 — Render with props4b — Pass a struct as props5 — Layouts with slots6 — Reuse existing templates7 — Client-side interactivityComponent systemGo API referenceHow-to guidesTutorial
Build your first htmlc component from scratch. This walkthrough takes you from installation to rendering a component with props and slots in about five minutes.
Step 1 — Install htmlc
Add the package to your Go module:
go get github.com/dhamidi/htmlc
The CLI is optional but handy for testing components without writing Go code. CLI equivalents are shown alongside each step below.
go install github.com/dhamidi/htmlc/cmd/htmlc@latest
Step 2 — Write a component
Create a directory called components/ and add a file named Card.vue:
<!-- components/Card.vue --> <template> <div class="card"> <h2>{{ title }}</h2> <slot>No content provided.</slot> </div> </template> <style scoped> .card { border: 1px solid #ccc; border-radius: 8px; padding: 1rem; } </style>
The {{ title }} interpolation reads the title prop. The <slot> element is a placeholder for content supplied by a parent component; its children are the fallback rendered when no content is provided.
Step 3 — Create an engine
Call htmlc.New with the directory that contains your .vue files. The engine discovers and registers every component automatically.
package main import ( "log" "github.com/dhamidi/htmlc" ) func main() { engine, err := htmlc.New(htmlc.Options{ ComponentDir: "./components", }) if err != nil { log.Fatal(err) } _ = engine }
CLI equivalent
There is no explicit "create engine" step from the CLI — htmlc render discovers components from a directory automatically. Use it as a quick smoke-test that a component is parseable:
htmlc render -dir ./components Card
Step 4 — Render with props
Call RenderFragment to write a component directly to an io.Writer. Prefer this over RenderFragmentString — it writes directly to an io.Writer and avoids allocating a full string copy. Pass props as a map[string]any.
err := engine.RenderFragment(os.Stdout, "Card", map[string]any{ "title": "Hello, htmlc!", }) if err != nil { log.Fatal(err) }
Expected output (style block prepended by the engine):
<style> .card[data-v-…]{border:1px solid #ccc;border-radius:8px;padding:1rem} </style> <div class="card" data-v-…> <h2>Hello, htmlc!</h2> No content provided. </div>
The fallback text "No content provided." is rendered because no slot content was passed. Step 5 shows how to supply it.
CLI equivalent
htmlc render -dir ./components Card -props '{"title":"Hello, htmlc!"}'
Step 4b — Pass a struct as props
Instead of building a map[string]any by hand you can pass any Go struct directly. The engine reads exported fields using their json struct tag (if present) and the Go field name otherwise.
Define a struct that mirrors the props your component expects:
type CardData struct { Title string `json:"title"` } data := CardData{Title: "Hello from a struct!"} err := engine.RenderFragment(os.Stdout, "Card", data) if err != nil { log.Fatal(err) }
The Card component template accesses {{ title }} exactly as before — nothing changes on the template side. Structs and maps are interchangeable from the template's point of view.
You can also spread a struct onto a child component using v-bind in a parent template:
<!-- components/PostPage.vue --> <template> <!-- The struct's fields become individual props of PostCard --> <PostCard v-bind="post" /> </template>
The engine accepts any struct or map[string]any as the right-hand side of v-bind. Embedded struct fields are promoted and resolved as if they were declared directly on the outer struct.
CLI equivalent
Props are always a JSON object from the CLI — the distinction between map and struct is only relevant in Go:
htmlc render -dir ./components Card -props '{"title":"Hello from a struct!"}'
Step 5 — Layouts with slots
Slots let a component own its structure while the caller supplies the content. The classic use case is a layout component: one component that provides the HTML skeleton (header, main, footer wrappers), reused across every page.
We'll build a PageLayout component with three slots, then use it in a HomePage component.
Step A — Define the layout component
Create components/PageLayout.vue. It declares a named header slot, the default (unnamed) slot for main content, and a named footer slot:
<!-- components/PageLayout.vue --> <template> <div class="page"> <header> <slot name="header"></slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer"></slot> </footer> </div> </template>
Named slots use <slot name="…">. The unnamed <slot> is the default slot — it receives any content not assigned to a named slot. You can place fallback content between the <slot> tags; it renders when the caller provides nothing for that slot.
Step B — Fill the slots from a page component
Create components/HomePage.vue. Use <template #name> to target each named slot; content outside any <template #…> goes to the default slot:
<!-- components/HomePage.vue --> <template> <PageLayout> <template #header> <nav><a href="/">Home</a> · <a href="/about">About</a></nav> </template> <h1>Welcome</h1> <p>This is the main content area.</p> <template #footer> <p>© {{ year }} My Site</p> </template> </PageLayout> </template>
The #header shorthand is equivalent to v-slot:header. See the component system reference for full named and scoped slot details.
Step C — Render from Go
Render HomePage the same way you would any other component. Pass the year prop to show data flowing from Go into the nested layout:
err := engine.RenderFragment(os.Stdout, "HomePage", map[string]any{ "year": 2024, }) if err != nil { log.Fatal(err) }
CLI equivalent
htmlc render -dir ./components HomePage -props '{"year":2024}'
Step D — Expected output
<div class="page" data-v-…> <header> <nav><a href="/">Home</a> · <a href="/about">About</a></nav> </header> <main> <h1>Welcome</h1> <p>This is the main content area.</p> </main> <footer> <p>© 2024 My Site</p> </footer> </div>
PageLayout owns the skeleton; HomePage owns the content. Now we can create any number of page components — AboutPage, BlogPage, and so on — all sharing the same layout without duplicating the HTML structure.
One sharp edge: slots are a template-composition mechanism — they are filled by parent .vue components, not by Go code. You cannot pass slot content through the props map or inject it via RenderFragment; that is by design. (If you are coming from Vue: there is no $slots key in the Go props map.)
Dynamic slot content from Go
If you need to inject a dynamic HTML string into a component from Go, use a regular prop with v-html instead of a slot:
<!-- components/Card.vue --> <div class="card"> <h2>{{ title }}</h2> <div v-html="body"></div> </div>
html, err := engine.RenderFragmentString(r.Context(), "Card", map[string]any{ "title": "Hello", "body": "<p>Dynamic content from Go</p>", })
Step 6 — Reuse existing templates
If you have existing html/template partials — headers, footers, shared snippets — RegisterTemplate lets you use them as component tags in .vue files without rewriting anything.
Register an existing template with the engine after creating it:
// Existing html/template code — no changes needed. headerTmpl := html.template.Must( html.template.New("site-header").Parse( `<header><h1>{{.title}}</h1></header>`, ), ) engine, err := htmlc.New(htmlc.Options{ComponentDir: "./components"}) if err != nil { log.Fatal(err) } if err := engine.RegisterTemplate("site-header", headerTmpl); err != nil { log.Fatal(err) }
After registration, the template is available as a component tag in any .vue file — no .vue file is needed for the old template itself:
<!-- pages/home.vue --> <template> <site-header :title="pageTitle"></site-header> <main>…</main> </template>
If your template file contains {{define}} blocks, each block is automatically registered as its own component under its block name. A multi-partial template file just works — you don't need to register each block separately.
Conversion limitsRegisterTemplate converts common Go template constructs to their .vue equivalents, but {{with}}, variable assignments ($x :=), and multi-command pipelines are not supported and will return an error. Nothing is registered if any conversion fails.
See the Go API reference for the full list of supported constructs.
Step 7 — Client-side interactivity
So far every step has been purely server-rendered. For interactive elements you can add a <script customelement> block to any .vue component. htmlc ships that block as a standard Web Component alongside the server-rendered HTML — no JavaScript framework required.
The components/ui/Counter.vue component that ships with htmlc demonstrates this pattern:
<!-- components/ui/Counter.vue --> <template> <button class="counter-demo">Count: <span>{{ initial }}</span></button> </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>
How it works: the <template> block is rendered server-side — the initial prop is interpolated into the <span> before the page is sent to the browser. The class in the <script customelement> block extends HTMLElement; connectedCallback fires as soon as the browser inserts the element into the DOM, at which point the click handler takes over and increments the displayed count. The prop value is "handed off" from Go to the browser without any extra wiring.
Wiring it up in Go
To serve the custom-element scripts, use RenderWithCollector to capture which scripts were used, then expose them via ScriptHandler:
collector := htmlc.NewCollector() html, err := engine.RenderWithCollector(r.Context(), "ui/Counter", map[string]any{ "initial": 0, }, collector) // Serve the collected scripts at /scripts/ http.Handle("/scripts/", http.StripPrefix("/scripts/", engine.ScriptHandler()))
Add importMap() to your page <head> so the browser can resolve the module references:
<head> {{ importMap() }} </head>
Rendered output
The engine wraps the server-rendered <template> output in the custom element tag. The browser upgrades it once the script loads:
<ui-counter> <button class="counter-demo">Count: <span>0</span></button> </ui-counter>
Live demo
Click the button to see the custom element in action:
Further reading
See the custom elements reference for the full API: ScriptHandler, WriteScripts, shadow DOM support, and more.