A simpler way to build interactive Go web apps · Alpha

Build interactive web apps in Go with standard HTML templates.

Use html/template and Go handlers to build rich app screens without writing JavaScript for the common cases.

livegreet · running in this page

Hello, there

↑ a real, running app. Type a name, hit Say hi. Below is the whole thing — the template and the Go code, complete:

app.tmpl  — the entire template, just standard HTML
<!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>
app.go  — the entire program
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.

Step 1 · Render

This is the whole app, not a toy example.

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.

browser
Hello, thereHello, Ada
WebSocket
▲ action · 40 B {"action":"greet","data":{"name":"Ada"}}
▼ diff · 20 B {"tree":{"0":"Ada"}}
Go server
Greet(state)
re-render diff

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.

One app, six steps

Start with a normal Go app. Then add the parts real apps need.

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.

Step 2 · Works without JavaScript

The same app. With and without JavaScript.

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.

liveJavaScript on · fetch + DOM patch
○ no JSJavaScript off · form POST → full render
app.tmpl · one form, either transport
<!-- 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.

Step 3 · Validation

Write the rule in HTML. Enforce it again on the server.

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:

livegreet-validate · server-checked

Hello, there

app.tmpl · the rule, written once
<input name="name" required {{.lvt.AriaInvalid "name"}}>
{{.lvt.ErrorTag "name"}}
app.go · the server re-checks, then adds its own rule
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
}
on the wire · HTTP fetch ▲ {"action":"greet","data":{"name":"admin"}} ▼ {"meta":{"errors":{"name":"\"admin\" is reserved"}}}
Step 4 · Loading state

Two ways to show pending state, in HTTP and WebSocket mode.

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.

livegreet loading server owned

Hello, there

app.tmpl · server-owned loading, only template variables
<button class="greet-btn" {{if .Loading}}type="button" aria-busy="true" disabled{{else}}name="greet"{{end}}>Say hi</button>
app.go · set Loading, then finish via server push
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.

on the wire · server-state version ▲ {"action":"greet","data":{"name":"Ada"}} ▼ {"tree":{"1":{"aria-busy":"true","disabled":true,"type":"button"}}} ▼ {"action":"finishGreet","data":{"name":"Ada"}} → {"tree":{"0":"Ada","1":{"name":"greet"}}}
livegreet loading attribute

Hello, there

app.tmpl · button-level pending with two lvt-* attributes
<button name="greet"
  lvt-el:addClass:on:pending="is-loading"
  lvt-el:removeClass:on:done="is-loading">Say hi</button>
app.go · no loading state machine needed
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.

on the wire · attribute version ▲ {"action":"greet","data":{"name":"Ada"}} ▼ {"tree":{"0":"Ada"}}
Step 5 · Sync your own tabs

Add WebSocket updates. Keep your tabs in sync.

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.

1 · state
state changes
2 · render
re-render template
3 · diff
diff vs last render
4 · patch
patch the browser
livegreet wall · WebSocket on

Hello, there

the server said hi at 22:25:02

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.

app.go · subscribe, publish on greet, and the Refresh it runs
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.

on the wire · WebSocket ▲ this tab · {"action":"greet","data":{"name":"Ada"}} ▼ your other tab · {"tree":{"0":"Ada"}}
app.go · the same session can be pushed by the server
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)
        }
    }
}
on the wire · server push ▼ {"tree":{"3":{"0":"15:04:08"}}} (no ▲ — the server started it; just the changed value goes down)
Step 6 · A wall everyone shares

Change the topic, and it becomes cross-user.

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.

livevisitor 1 · WebSocket on

Hello, there

the server said hi at 22:25:02

livevisitor 2 · WebSocket on

Hello, there

the server said hi at 22:25:02

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.

on the wire · WebSocket ▲ visitor 1 · {"action":"greet","data":{"name":"Ada"}} ▼ visitor 2 · {"tree":{"3":[["a",[{"0":"Ada","1":"15:04"}]]]}}
app.go · the topic is the only difference
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
}
app.go · admit the shared topic
lvt.WithTopicACL(func(topic, _ string, _ *http.Request) (bool, error) {
    return topic == "wall", nil   // deny-all by default; admit just this one
})
Only the diff goes over the wire

Send what changed, not the whole page.

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.

full HTML2.4 KB
lvt diff340 B
↓ 86% smaller per update
UI Patterns

Want deeper demos?

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.

And so much more

The pieces real Go apps need.

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.

How it compares

Others add a frontend layer. LiveTemplate keeps the app in Go.

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…
htmxA similar HTML-first feel, but with server-owned state and DOM diffing built in, so there is less request wiring in markup.
templ + htmxUse Go's built-in html/template and keep live behavior in one app model instead of composing multiple layers.
Alpine.jsHandle richer app behavior without introducing a separate client-side state model for common server-rendered screens.
Phoenix LiveViewA comparable server-driven model, but staying in Go and still falling back cleanly to plain HTTP forms.
React SPAGet 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 →

Build a real Go web app. Start in 30 seconds.

$ go get github.com/livetemplate/livetemplate
⚠ Alpha — core features work and are tested; the API may change before v1.0