Reactive web UIs in standard HTML and Go

LiveTemplate is a Go library for building reactive web UIs from standard html/template templates. You write a template and a controller struct; when state changes, the template re-renders on the server and only the diff is sent to the browser. The same code runs three ways: a plain <form> POST that reloads the page, a fetch() request that patches the DOM in place, or a WebSocket session where other tabs sync automatically.

Alpha — core features work and are tested, but the API may change before v1.0.

Try it

Click the buttons. Each click POSTs the action to the Go server; the server runs Increment, re-renders the template, diffs against the previous render, and sends only the changed text node back. The form, the buttons, and the count display are never re-created — only the count's text changes. Open the page in a second tab in the same browser session: clicks in one tab show up in the other over WebSocket, because the state is keyed to your session.

The iframe loads a real, deployed LiveTemplate app running standalone at lt-landing-demo.fly.dev — the same code you'd write yourself.

The code that runs the demo above

The whole app is two files. The controller:

type CounterController struct{}

type CounterState struct {
    Count int `json:"count" lvt:"persist"`
}

func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
    s.Count++
    return s, nil
}

func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
    s.Count--
    return s, nil
}

func (c *CounterController) Reset(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
    s.Count = 0
    return s, nil
}

The template:

<p class="count">{{.Count}}</p>
<form method="POST">
    <fieldset role="group">
        <button name="decrement">−1</button>
        <button name="reset">Reset</button>
        <button name="increment">+1</button>
    </fieldset>
</form>

The wire-up (the rest of main.go):

tmpl := livetemplate.Must(livetemplate.New("counter",
    livetemplate.WithParseFiles("counter.tmpl"),
))
handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))
http.ListenAndServe(":8080", handler)

A button's name attribute IS the routing key — <button name="increment"> posts increment and LiveTemplate dispatches to the Increment method on the controller. The protocol between HTML and Go is just the form data the browser already sends. The lvt:"persist" tag on Count makes the field survive WebSocket reconnects and propagate across tabs in the same session.

See the full source on GitHub →

What happens between a click and a DOM update

ServerBrowserServerBrowserAdd() returns new state(Items: [...] → [..., new])Tree diff calculatedOnly changed values sentDOM patched in place(no full re-render)User clicks button{action: "add", form: {title: "Buy milk"}}{patches: [...]}

When a user clicks a button, LiveTemplate calls a method on your Go struct, diffs the template output against the previous render, and sends only what changed.

See the full architecture walkthrough →

Get started

  1. Installgo get, ~30 seconds
  2. Your First App — counter app from scratch in 10 minutes
  3. Progressive Complexity — when to reach for lvt-* attributes (and when not to)
  4. Patterns catalog — 33 interactive UI patterns, live demos with source

Or browse

How this site is built

This is a tinkerdown site. Most pages are mirrored from canonical files in the source repos (livetemplate, client, lvt, examples) and re-published on each release. Pattern detail pages are reverse-proxied to a deployed livetemplate/examples/patterns showcase. The "Edit this page on GitHub" link in every footer points to the canonical source — that's where corrections should land. See How This Docs Site Works for the full dogfood loop.