Use html/template and Go handlers to build rich app screens without writing JavaScript for the common cases.
↑ a real, running app. Type a name, hit Say hi. Below is the whole thing — the template and the Go code, complete:
<!DOCTYPE html> <html><head> <script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client"></script> </head><body> <h1>Hello, {{.Name}}</h1> <form method="POST"> <input name="name" placeholder="Your name"> <button name="greet">Say hi</button> </form> </body></html>
package main
import (
"net/http"
lvt "github.com/livetemplate/livetemplate"
)
type State struct{ Name string }
type App struct{}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
s.Name = ctx.GetString("name")
return s, nil
}
func main() {
app := lvt.Must(lvt.New("app", lvt.WithParseFiles("app.tmpl")))
http.ListenAndServe(":8080",
app.Handle(&App{}, lvt.AsState(&State{Name: "there"})))
}That's the whole app — ~20 lines of Go and standard HTML. No SPA framework, no REST API, no build step.
It reads like a classic server-rendered form, but it behaves like a modern app: the page updates live, with no full reload and no JavaScript you had to write. From here, everything you'd reach for a frontend framework to do — form validation, loading states, progressive enhancement, real-time sync across tabs, multiplayer/collaborative views, file uploads — is a small diff on this exact code. The rest of this page builds them, one step at a time.
These are the real frames on the wire. A form submit calls your Go method, the server re-renders the template, and only the changed HTML comes back — no reload, no extra JSON API, no client route you had to build.
All of it comes from the two files above plus one <script>. The framework handles transport and DOM patching, so you stay in Go handlers and HTML templates.
Everything below is the same greeting app. We add plain POST fallback, validation, pending state, then WebSocket updates. Each step is a small diff. You keep one Go codebase and one place for application logic.
Both cards run the identical app, with WebSocket off. Left, JS on: the browser enhances the form submit and patches the headline in place. Right, JS disabled: the same <form> does a plain POST and the server renders the page. JavaScript changes the browser behavior, not the app you have to build. Type a name in each.
<!-- the only line that flips the transport: --> <script defer src="…@livetemplate/client"></script><form method="POST"> <!-- JS on → fetch + patch · JS off → native POST --> <input name="name"> <button name="greet">Say hi</button> </form>
Same <form> and the same Greet handler as Step 1 — no if jsEnabled branch anywhere. When the <script> loads, the client enhances the submit; when it doesn't, the browser falls back to a native POST.
Use standard HTML attributes like required and type="email". ctx.ValidateForm() re-runs the same rules server-side, then you can add Go-only checks for business rules. Submit empty, or type admin:
<input name="name" required {{.lvt.AriaInvalid "name"}}> {{.lvt.ErrorTag "name"}}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
if err := ctx.ValidateForm(); err != nil { // re-runs the HTML rules server-side
return s, err
}
name := strings.TrimSpace(ctx.GetString("name"))
if strings.EqualFold(name, "admin") { // a rule HTML can't express
return s, lvt.NewFieldError("name", errors.New(`"admin" is reserved`))
}
s.Name = name
return s, nil
}LiveTemplate works in both plain HTTP and live-session WebSocket mode. You can model loading in server state with ordinary template conditionals, or use a small button-level escape hatch when the server code should stay unchanged. The server-state version below needs a live session connection for its follow-up push; the attribute version works as a single request/response.
<button class="greet-btn" {{if .Loading}}type="button" aria-busy="true" disabled{{else}}name="greet"{{end}}>Say hi</button>
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
if s.Loading {
return s, nil
}
session := ctx.Session()
if session == nil {
return s, nil
}
name := strings.TrimSpace(ctx.GetString("name"))
s.Loading = true
go func() {
time.Sleep(700 * time.Millisecond)
_ = session.TriggerAction("finishGreet", map[string]any{"name": name})
}()
return s, nil
}
func (a *App) FinishGreet(s State, ctx *lvt.Context) (State, error) {
s.Name = ctx.GetString("name")
s.Loading = false
return s, nil
}This version keeps loading entirely in server state, but it needs a second action over the live session to clear the spinner. It is a good fit when loading is part of the app's actual state machine.
<button name="greet" lvt-el:addClass:on:pending="is-loading" lvt-el:removeClass:on:done="is-loading">Say hi</button>
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
time.Sleep(700 * time.Millisecond)
if name := strings.TrimSpace(ctx.GetString("name")); name != "" {
s.Name = name
}
return s, nil
}This version keeps the Go code simpler by leaving pending UI out of server state. It works as a single request/response, so use it when the loading indicator is just button chrome rather than meaningful application state.
Subscribe this browser session to its own topic and publish after a handler runs — two calls — and your greeting syncs across every open tab. The same live session also lets the server push first when it has something new to say.
Open this page in a second tab, greet in either, and your headline updates in both — live, no reload. The same connection also allows server-initiated refreshes in this app, without waiting for a user click. This is the kind of step from "single-page form" to "real workflow" that usually pushes teams toward a separate frontend.
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
ctx.Subscribe(ctx.SelfTopic()) // your tabs share a topic
s.Name = a.name(ctx.GroupID()) // load your latest name
return s, nil
}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
a.setName(ctx.GroupID(), sanitize(ctx.GetString("name")))
ctx.Publish(ctx.SelfTopic(), "Refresh", nil) // run Refresh on your other tabs
return s, nil
}
// Refresh is an ordinary action — the publish above runs it on each peer tab.
func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
s.Name = a.name(ctx.GroupID()) // re-read state, then re-render
return s, nil
}No magic: Publish(ctx.SelfTopic(), "Refresh", nil) just runs your Refresh method on your other tabs. It re-reads shared data and returns new state; the framework diffs and patches.
func (a *App) OnConnect(s State, ctx *lvt.Context) (State, error) {
a.keep(ctx.GroupID(), ctx.Session()) // remember who's connected
return s, nil
}
func (a *App) heartbeat() {
for range time.Tick(30 * time.Second) {
a.serverAt = now() // replace one slot in place
for _, sess := range a.sessions {
sess.TriggerAction("ServerRefresh", nil)
}
}
}Swap the self-topic for a shared topic — admitted by a small ACL — and the same publish fans out to every visitor. The two cards below are separate sessions, like two different people. Greet in one and your line lands on the other's wall, live.
Two independent sessions, one shared wall — type in either card and watch the list appear in both. This is the same pattern you would use for shared dashboards, approval queues, team status boards, or lightweight collaboration.
Headlines stay independent (each card is its own session), but the wall is global — so a greeting crosses from one session to the other. That's the whole cross-user story: the same two pub/sub calls as step 5, with a different topic and an ACL around it.
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
ctx.Subscribe("wall") // a shared, cross-user topic
return s, nil
}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
a.append(sanitize(ctx.GetString("name"))) // shared, capped, ephemeral
ctx.Publish("wall", "WallRefresh", nil) // fan out to every visitor
return s, nil
}lvt.WithTopicACL(func(topic, _ string, _ *http.Request) (bool, error) {
return topic == "wall", nil // deny-all by default; admit just this one
})Templates split into static structure (cached) and dynamic values. On change, LiveTemplate sends only the changed values — typically 85%+ less bandwidth than re-sending full HTML. A greeting comes back as {"tree":{"0":"Ada"}}, not a page.
The UI patterns catalog breaks these ideas out into focused examples: loading states, inline validation, SPA-style navigation, sortable tables, pubsub, presence, server push, and more.
This is aimed at the kinds of apps Go teams actually ship: admin screens, internal tools, CRUD flows, dashboards, approval systems, uploads, auth, and lightweight collaborative views.
Handle uploads in the same app and show live progress, without bolting on a separate upload flow.
Keep tabs, dashboards, queues, and team screens in sync with Subscribe and Publish.
Keep UI state on the server, scoped per browser or per user, without leaking data across sessions.
Return validation and business-rule errors from Go and render them back into the same template.
Generate a starting point for common app shapes so teams can get to real screens faster.
The browser layer handles DOM patching and transport so your application logic stays in Go.
Measure handler timings, update paths, and runtime behavior with hooks for metrics and tracing.
Run the same model in production with guidance for session groups, fan-out, and deployment shape.
Other tools carry more behavior in the markup — hx-*, x-*, phx-*, or a DSL. Here a plain <button name="greet"> is already the action, handlers stay in Go, and state lives on the server. lvt-* attributes exist only as an escape hatch for what HTML can't express.
| If you’re using… | LiveTemplate gives you… |
|---|---|
| htmx | A similar HTML-first feel, but with server-owned state and DOM diffing built in, so there is less request wiring in markup. |
| templ + htmx | Use Go's built-in html/template and keep live behavior in one app model instead of composing multiple layers. |
| Alpine.js | Handle richer app behavior without introducing a separate client-side state model for common server-rendered screens. |
| Phoenix LiveView | A comparable server-driven model, but staying in Go and still falling back cleanly to plain HTTP forms. |
| React SPA | Get modern app behavior for forms, CRUD, dashboards, and shared views without splitting the product into an API plus a frontend app. |
Built in Go. This site proves the point. Every step above is a real LiveTemplate app, embedded live through this docs site — which itself runs on LiveTemplate + tinkerdown. See how this site works →