You're going to build a counter. The plain version takes about 5 minutes. The fully reactive multi-tab version takes another 5. By the end you'll have seen every layer of the LiveTemplate model — and you'll have been clicking the same widget you wrote, embedded right in this page.
Prerequisite: Go 1.22 or later, and you've already run
go get github.com/livetemplate/livetemplatein some directory.
mkdir counter && cd counter
go mod init counter
go get github.com/livetemplate/livetemplate
You'll have a go.mod and an empty directory. We'll add three files: counter.go (state and handlers), main.go (wiring), and counter.tmpl (the template).
Create counter.go. First the state:
// CounterState is per-session state — pure data, cloned per session by
// livetemplate. AnonymousAuthenticator (handler.go) keeps state private per
// browser, so each visitor gets their own count with nothing shared across
// users. This is the basic, single-session version of the counter; the
// pubsub variant in examples/counter adds cross-tab sync on top.
type CounterState struct {
Counter int
}
State is a value type, not a pointer — controllers receive a copy and return a (possibly modified) copy. The framework manages the swap.
Then a controller and two action methods:
// CounterController holds shared dependencies (none in this demo) and
// exposes action methods invoked by name from the template.
type CounterController struct{}
// Increment is invoked when the user clicks the "+1" button. The runtime
// calls it with a clone of the current state and stores whatever you return —
// the new render is diffed against the previous one and only the changed text
// node is sent to the browser.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter++
return s, nil
}
// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter--
return s, nil
}
Action methods are exported on the controller, and their names ARE the action names — Increment and Decrement are what the template will reference. That's the whole app for now; we'll add multi-tab sync in Step 6 by extending this same file.
Now wire it up in main.go:
package main
import (
"log"
"net/http"
"github.com/livetemplate/livetemplate"
)
func main() {
tmpl := livetemplate.Must(livetemplate.New("counter",
livetemplate.WithParseFiles("counter.tmpl"),
))
handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))
mux := http.NewServeMux()
mux.Handle("/", handler)
log.Fatal(http.ListenAndServe(":9090", mux))
}
livetemplate.New("counter") parses counter.tmpl from the same directory. tmpl.Handle(controller, AsState(initial)) is the standard wiring — controller for actions, initial state for new sessions.
By default LiveTemplate uses AnonymousAuthenticator, which gives each browser a stable session group via cookie. Two consequences worth knowing about now: each browser gets its own state (no cross-user leaks), and tabs from the same browser share that session group — the identity the peer-fan-out demo at Step 6 builds on.
Create counter.tmpl:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Counter</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/livetemplate.css">
<script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
</head>
<body>
<main class="container">
<h1>Counter: {{.Counter}}</h1>
<form method="POST" style="display:inline">
<button name="increment">+1</button>
<button name="decrement" class="secondary">-1</button>
</form>
</main>
</body>
</html>
The <button name="increment"> attribute is the routing trigger — clicking that button posts the form and the framework calls Increment() on the controller.
The two <link> and <script> tags in <head> load the LiveTemplate JS client; we'll see what they do at Step 5.
go run .
Open http://localhost:9090 in your browser to see your local counter. Or click +1 and -1 right here — the same source files, served by this docs site, running below:
Click and the count changes — no full-page reload, just a DOM patch streamed over WebSocket. That's the JS client at work.
Remove these two lines from the template:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/livetemplate.css">
<script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
…and the counter still works. Each click does a full form POST and page reload (you'll see a brief flash). The framework re-renders. The browser navigates. No JavaScript needed.
This is LiveTemplate's Tier 1: forms POST, server re-renders, browser navigates. Add the JS client back (the two CDN lines) and the framework opens a WebSocket — your click sends a frame instead of a form POST, the server diffs the new render against the previous, and only the changed text node (Counter: 1 → Counter: 2) is sent back as a patch.
Same Go code. Same template. Two lines of HTML promote the experience from server-rendered-with-reload to in-place reactive.
So far the counter reacts within a single tab. To make every tab of the same session stay in lockstep, go back to counter.go and add two things — a Mount method, and one line per action (highlighted):
// Mount subscribes the self-topic so peer tabs of the same session receive
// the Increment / Decrement dispatches Publish'd from the actions below.
func (c *CounterController) Mount(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return s, err
}
return s, nil
}
// Increment is invoked when the user clicks the "+1" button. The runtime
// calls it with a clone of the current state and stores whatever you return.
// The Publish call tells peer tabs subscribed to the same SelfTopic() to run
// Increment too, keeping multiple embeds and tabs in lockstep.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter++
if err := ctx.Publish(ctx.SelfTopic(), "Increment", nil); err != nil {
return s, err
}
return s, nil
}
// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
s.Counter--
if err := ctx.Publish(ctx.SelfTopic(), "Decrement", nil); err != nil {
return s, err
}
return s, nil
}
Two things make multi-tab sync work. In Mount, ctx.Subscribe(ctx.SelfTopic()) opts the connection in to peer fan-out for its own session (SelfTopic() resolves to the reserved-namespace string lvt:session:<groupID> and is ACL-exempt). Then in each action, ctx.Publish(ctx.SelfTopic(), "Increment", nil) (and the matching Decrement) fans the named action out to every other connection that subscribed. Without the Subscribe, the Publish has no receiver; without the Publish, no peer ever runs the action. With both, the tabs stay in lockstep.
To prove it, here are two embeds against the same counter, side by side:
Click +1 in one — watch the other update in real time. They're talking to the same upstream session, and the Mount-side Subscribe(SelfTopic()) plus action-side Publish(SelfTopic(), ...) are what makes them stay synced. (On a narrow viewport the embeds stack vertically — the fan-out still works.)
Why does this stay scoped to your browser? LiveTemplate's default authenticator (
AnonymousAuthenticator) uses a cookie to assign each browser a stable session group. Tabs from the same browser share that group — that's why the two embeds above sync. Different browsers — or an incognito window in the same browser — get different cookies, different groups, and isolated state. For a public docs site this is the right default: every visitor gets a clean slate, and the peer-fan-out demo still proves the feature within their own browser. See Recipes/Counter, deeper for the full session-group + scaling story.
You wrote a counter that:
…in about 50 lines of Go and HTML, with no build step, no client-side framework, no custom template language. The two embeds above? They're the same code rendered live. Every click you've done has gone through your handler, published to peer tabs via ctx.Publish, and patched the DOM.
lvt-* attributes (Tier 2) and when to stay in Tier 1.New, Handle, Context, action method dispatch.Subscribe/Publish peer fan-out vs TriggerAction(), and how sessions are scoped.