LiveTemplate makes standard HTML reactive by default. A plain <form method="POST"> with <button name="add"> is interactive at every transport level — no framework-specific attributes required. This guide explains how it works, how it compares to other frameworks, and the tradeoffs involved.
Recent reinforcement: As of client v0.8.38, the TypeScript client and the generated templates went through a deliberate "attribute reduction" pass that removed
lvt-*attributes from anything HTML can already express. Tier 1 standard HTML is now the default everywhere; Tier 2 attributes are reserved for behaviors HTML genuinely cannot express (timing, keyboard shortcuts, reactive DOM).
Every interactive feature in a traditional web app requires the same ceremony: design a REST endpoint, write a serializer, manage client-side state, update the DOM, and wire it all together. That overhead discourages interactivity — teams leave things static not because they should be, but because the plumbing isn't worth it. As Chris McCord put it when explaining why he built Phoenix LiveView: conventional frameworks make you "fetch the world, munge it into some format, and shoot it over the wire... then throw all that state away" on every request.
LiveView's answer was to keep all state on the server and push rendered updates over a persistent connection. LiveTemplate brings that approach to Go, with one major difference: it works equally well over standard HTTP. And it goes a step further — the HTML itself needs no framework-specific attributes for core interactions.
The name attribute on a button routes to a Go method:
<button name="add">Add</button> <!-- routes to Add() -->
<button name="delete">Delete</button> <!-- routes to Delete() -->
This uses standard HTML semantics — the button name is included in form data on submit. LiveTemplate reads it and dispatches to the matching method. No custom attributes needed.
All <form> elements inside a LiveTemplate handler are automatically intercepted:
fetch(), and patches the DOM with the response. No page reload.The same HTML works identically across all three modes.
For production form validation, use ctx.BindAndValidate() with Go struct tags:
// validate is a *validator.Validate from github.com/go-playground/validator/v10,
// typically initialized once and stored on the controller.
var input struct {
Email string `validate:"required,email,min=5"`
}
if err := ctx.BindAndValidate(&input, c.validate); err != nil {
return state, err // field errors sent to template automatically
}
For HTML-attribute-based validation (required, pattern, min, max), see the Error Handling reference for the ValidateForm + WithFormSchema pattern.
When one user's action should be visible to other WebSocket-connected tabs, use BroadcastAction. Note: BroadcastAction must be called AFTER all state mutations and ctx.With*() calls, because With*() creates shallow copies and broadcasts queued before the copy won't propagate.
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.Items = append(state.Items, Todo{Title: ctx.GetString("title")})
// BroadcastAction after all state changes — pushes to other WS-connected tabs
ctx.BroadcastAction("Refresh", nil)
return state, nil
}
func (c *TodoController) Refresh(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.Items = c.loadItems()
return state, nil
}
Broadcast is scoped to the session group. For multi-instance deployments, add Redis pub/sub:
tmpl, _ := livetemplate.New("app",
livetemplate.WithPubSubBroadcaster(redisBroadcaster),
)
See PubSub Reference for details.
Every major reactive framework requires custom attributes on HTML elements. LiveTemplate is unique in making standard HTML reactive without modification.
| Framework | Form markup required | Custom attributes |
|---|---|---|
| htmx | <form hx-post="/todos" hx-target="#list"> |
hx-post, hx-target, hx-swap, hx-trigger |
| Laravel Livewire | <form wire:submit="add"> |
wire:submit, wire:model, wire:click |
| Phoenix LiveView | <form phx-submit="add"> |
phx-submit, phx-click, phx-change |
| LiveTemplate | <form method="POST"> |
None for standard interactions |
htmx extends HTML with hx-* attributes for AJAX interactions. A form without hx-post submits normally (full page reload). Every interactive element needs explicit hx-* attributes.
Livewire uses wire:* directives in PHP/Blade templates. wire:submit captures form submissions, wire:model enables two-way binding. State is serialized into HTML attributes.
LiveView uses phx-* attributes and requires a persistent WebSocket connection. Forms need phx-submit to route actions. The initial page renders as static HTML, then upgrades to WebSocket.
Standard HTML forms work reactively without any framework attributes. The button name routes to a Go method, form data is available via ctx.GetString(), and the response is a minimal tree diff. WebSocket is optional — only needed for server-initiated broadcasts.
LiveTemplate is inspired by Phoenix LiveView but does not yet cover its full feature set. Tracked gaps as of v0.8.23:
| Feature | LiveView | LiveTemplate | Notes |
|---|---|---|---|
| Live Navigation | push_navigate, push_patch |
Partial — __navigate__ action covers same-handler query-string navigation (no reconnect). Different-handler nav still falls back to fetch or full page load. |
See Navigate Action. |
| Stateful Components | LiveComponent with own lifecycle |
Stateless templates only | {{template}} invocations work but have no component-level state or event handling. |
| Streams | stream/3 for large lists |
Not yet | LiveView streams handle large/infinite lists without keeping all items in server memory. Streaming-range rendering (PRs #366/#368/#369/#370) is the latest step toward this. |
| JS Commands | JS.push, JS.toggle, JS.show |
Partial | lvt-* reactive attributes cover common cases (disable, add/remove class, set attribute) but aren't as composable as LiveView's server-defined JS chains. |
| Client Hooks | phx-hook lifecycle callbacks |
Proposed | lvt-hook proposal covers third-party JS library integration; not yet shipped. |
| Presence | Phoenix.Presence |
Not built-in | Can be built on LiveTemplate's session stores; requires manual implementation. |
| Testing Helpers | live/2, render_click/3 |
Minimal | AssertPureState exists; no view-level test DSL. Browser tests use chromedp. |
| Form Recovery | Automatic on reconnect | Partial — lvt-form:preserve retains specific fields across re-renders |
Full automatic recovery on WS reconnection is not yet built in. |
For day-to-day workarounds, see Current Limitations.
LiveTemplate follows a two-tier model:
| Tier | What you write | When to use |
|---|---|---|
| Tier 1: Standard HTML | <form>, <button name="add">, <dialog>, <a href> |
Forms, actions, modals, navigation |
Tier 2: lvt-* attributes |
lvt-on:, lvt-mod:debounce, lvt-el:, lvt-fx: |
Timing, keyboard shortcuts, reactive DOM |
Tier 2 is only for behaviors standard HTML cannot express. For example, debounced search requires lvt-mod:debounce because HTML has no timing mechanism:
<input name="Query" value="{{.Query}}"
lvt-on:input="search" lvt-mod:debounce="300"
placeholder="Search...">
See the Progressive Complexity Guide for the complete walkthrough.
| Approach | Philosophy | Clarity | Flexibility |
|---|---|---|---|
| Custom attributes (htmx, Livewire, LiveView) | Explicit is better than implicit | High — clear what's reactive | High — opt-in reactivity |
| Standard HTML (LiveTemplate) | Make the common case simple | Lower — everything is reactive | Lower — opt-out via lvt-form:no-intercept / lvt-nav:no-intercept |
Advantages of LiveTemplate's approach:
Disadvantages:
name is less explicit than URL-based routinglvt-* attributeslvt-* reference