On this page1 — 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 guides

Tutorial

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>&copy; {{ 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>&copy; 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 limits
RegisterTemplate 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.