The UI runs on the server. You write standard html/template markup and a Go controller; the browser sends ordinary form data. The server runs a controller method, re-renders the template, diffs the result, and updates the browser.
The important constraint is also the useful part: start with HTML that works as a normal form POST, then opt into richer behavior only where the workflow needs it.
Every reactive thing here is the same four steps:
state changes → re-render the template → diff against the last render → patch the browser
A user clicking a button runs it. A second tab reacting to that click runs it. A different user seeing a live update runs it. The server pushing an update on its own — a timer, a job finishing — runs it. There's no separate real-time engine underneath: a second tab catching up and a background job pushing an update are the same pipeline, entered at a different point. What differs is when the action is enqueued and which connections receive the resulting patch:
| What happened | When the action runs | Who gets the patch |
|---|---|---|
| This user clicked | Immediately, on their connection | This connection |
| This user started slow work | Twice — once now, once when livetemplate.Async work completes |
This connection |
| Another tab should follow | After the action, via ctx.Publish |
Same user's other tabs (or any subscribed connections) |
| Another user should follow | After the action, via ctx.Publish to a shared topic |
Everyone subscribed to that topic |
| The server decided | Whenever the server calls session.TriggerAction |
The target connection(s) |
Because it is one pipeline, the program does not get a new shape as it grows. The counter you build in Your First App and a multi-user booking system are the same program with more controller methods and a Subscribe/Publish call — not a single-page app bolted onto a server. You never learn a second model.
A small LiveTemplate app usually starts with three files:
main.go wires the template, controller, initial state, and HTTP handler..tmpl file contains ordinary HTML and Go template expressions.The Your First App tutorial uses this shape directly: main.go, counter.go, and counter.tmpl.
Buttons and forms provide the routing key. A button like this:
<button name="increment">+1</button>
dispatches to this controller method:
func (c *CounterController) Increment(state CounterState, ctx *livetemplate.Context) (CounterState, error) {
state.Count++
return state, nil
}
The method receives the current state and returns the next state. Return an error and the state does not commit; the template can render the error instead.
The controller is where dependencies live: databases, loggers, clients, and other long-lived services. State is per session group and should contain serializable UI state.
By default, anonymous visitors get a stable session group through a cookie. Tabs from the same browser share that group; different browsers get isolated groups. Authenticated apps can define their own grouping through an authenticator.
The same controller action can run in three modes:
| Browser capability | What happens |
|---|---|
| JavaScript disabled | The form submits normally and the browser navigates to the server-rendered response. |
| JavaScript enabled, WebSocket disabled | The client intercepts the form, sends HTTP, and patches the DOM in place. |
| JavaScript and WebSocket enabled | The client sends the action over WebSocket and receives DOM patches on the same connection. |
The app code does not need separate handlers for those modes. Progressive enhancement is a transport concern, not a different application model.
After an action changes state, LiveTemplate renders the template on the server and compares it to the previous render. The browser receives the changed parts and patches the current DOM instead of replacing the whole page.
That means templates remain the source of truth. The browser client is there to preserve focus, submit actions, apply patches, and handle optional client attributes; it is not a second application.
Give repeated items a stable data-key and the diff can patch a row in place instead of removing and re-inserting it — see Delete Row for the shape.
This holds for nested structures too. A {{define}} block may invoke itself, so file trees, comment threads, and nested navigation render as ordinary templates and stay inside the reactive tree — editing one leaf five levels down sends a patch addressing that leaf, not its whole branch. The default depth cap is 128 (WithMaxTemplateDepth, or LVT_MAX_TEMPLATE_DEPTH) so self-referential data surfaces an error instead of overflowing the stack. The File Tree recipe is a worked example.
Use plain HTML first:
<form method="POST"> for submits.name attributes for actions.Reach for lvt-* attributes when HTML cannot express the interaction cleanly: debounced input, instant client-side pending feedback, client-side DOM effects, click-away behavior, or SPA-style navigation that should keep the current LiveTemplate session.
Server-owned loading is deliberately not on that list. livetemplate.Async runs slow work off the event loop and {{.lvt.Pending}} renders the spinner, both through ordinary template conditionals — no attributes involved.
You don't need pub/sub for a single form updating a single tab. Add it when another connection needs to react to an action.
The smallest common case is same-user multi-tab sync:
func (c *Controller) Mount(state State, ctx *livetemplate.Context) (State, error) {
_ = ctx.Subscribe(ctx.SelfTopic())
return state, nil
}
func (c *Controller) Save(state State, ctx *livetemplate.Context) (State, error) {
// mutate state or durable storage
ctx.Publish(ctx.SelfTopic(), "Refresh", nil)
return state, nil
}
Subscribe opts the current connection into a topic. Publish sends an action to subscribed peers after the current action succeeds. Without both parts, no peer update happens.
lvt-*.Subscribe/Publish peer fan-out, and Server push covers server-initiated actions, in more detail.