Alpha · a Go library for server-rendered app screens

Build interactive web apps in Go with standard HTML templates.

Write html/template and Go handlers. The server re-renders on each action and patches only what changed. Every demo below is a real app running on this page, and none of it is JavaScript you had to write.

Get started → Read the docs go get github.com/livetemplate/livetemplate
The app

Everyone reading this page writes to the same wall.

Type a name. Your headline updates and your line joins the wall underneath it, next to whoever else is here right now. Open this page in a second tab and watch the same line land there, with no reload. The whole program is directly below.

greet-wall · live, shared with every visitor

Hello, there

the server said hi at 13:36:46

  • Hdhhd said hi 12:45:16
  • Hdhs said hi 12:45:20
  • jjijiji said hi 14:19:52
  • sex said hi 14:19:57
  • porn said hi 14:20:00
  • gay said hi 14:20:03
  • gay said hi 14:20:16
  • porn said hi 14:20:18
  • hello world said hi 15:57:39
  • Quarterly report said hi 16:08:36
  • 42 said hi 16:08:45
app.tmpl — the whole template, verbatim
<script defer src="{{lvtClientScriptURL}}"></script>
<h1>Hello, {{.Name}}</h1>
<form method="POST">
  <input name="name" placeholder="Your name" required {{.lvt.AriaInvalid "name"}}>
  {{.lvt.ErrorTag "name"}}
  <button name="greet">Say hi</button>
</form>
<ul>
  {{range .Wall}}<li><b>{{.Name}}</b> said hi {{.At}}</li>{{end}}
</ul>
app.go — the whole controller, and the wiring
type State struct {
    Name string      // your headline, synced across your tabs
    Wall []Greeting  // the shared list, synced across everyone
}
func (a *App) Mount(s State, ctx *lvt.Context) (State, error) {
    ctx.Subscribe(ctx.SelfTopic())   // your own tabs
    ctx.Subscribe("wall")            // every visitor
    s.Name, s.Wall = a.nameFor(ctx.GroupID()), a.snapshot()
    return s, nil
}
func (a *App) Greet(s State, ctx *lvt.Context) (State, error) {
    if err := ctx.ValidateForm(); err != nil {
        return s, err                        // re-runs the HTML rules
    }
    name := sanitize(ctx.GetString("name"))
    if name == "" {
        return s, lvt.NewFieldError("name", errors.New("Please enter a name"))
    }
    if strings.EqualFold(name, "admin") {    // a rule HTML can't express
        return s, lvt.NewFieldError("name", errors.New(`"admin" is reserved`))
    }
    a.saveName(ctx.GroupID(), name)   // so Refresh can re-read it on your tabs
    a.appendWall(name)                // the one shared list everyone sees
    // A publish skips the connection that called it, so this one renders
    // from the values returned here.
    s.Name, s.Wall = name, a.snapshot()
    ctx.Publish(ctx.SelfTopic(), "Refresh", nil)  // your other tabs
    ctx.Publish("wall", "WallRefresh", nil)       // everyone else
    return s, nil
}
// A publish just runs an ordinary action on the peers it reaches.
func (a *App) Refresh(s State, ctx *lvt.Context) (State, error) {
    s.Name = a.nameFor(ctx.GroupID()); return s, nil
}
func (a *App) WallRefresh(s State, ctx *lvt.Context) (State, error) {
    s.Wall = a.snapshot(); return s, nil
}
func main() {
    app := lvt.Must(lvt.New("wall",
        lvt.WithParseFiles("app.tmpl"),
        // Developer topics are deny-all; admit just this one. Each user's
        // own SelfTopic() is always permitted.
        lvt.WithTopicACL(func(topic, _ string, _ *http.Request) (bool, error) {
            return topic == "wall", nil
        })))
    http.ListenAndServe(":8080", app.Handle(&App{}, lvt.AsState(&State{Name: "there"})))
}

That is the whole interface: a template, four methods and a main, with no JavaScript you had to write. The running demo adds about forty more lines of ordinary Go — sanitize, the map writes behind saveName, a twenty-line cap in appendWall and a per-session throttle — none of which is framework API. Read the real file.

Line by line

Four of those lines are worth a second look. One thing is missing.

Each section below picks out a line you have just read and runs it here as its own app, so you can try it on its own. The exception is the pending state: the wall answers instantly, so it has no slow work to show, and that section borrows two other apps instead.

No attributes

The button's name is the action.

<button name="greet"> calls Greet. That's the whole binding — no hx-post, no onClick, no route to register. Strip the wall away and the same idea is a complete app in twenty lines, this time with nothing elided at all.

greet · the smallest version

Hello, there

app.go · complete, nothing elided
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"})))
}

There are lvt-* attributes, but only for behavior HTML cannot express — a debounce, a keyboard shortcut, a class toggle. They're an escape hatch, not the interface.

No JavaScript

The same app works with scripting switched off.

One <script> tag is the only difference between the two cards below. With it, the client enhances the submit and patches the headline. Without it, the same <form> does a native POST and the server renders the page. There's no if jsEnabled branch anywhere in the Go.

JavaScript on · fetch + patch
JavaScript off · form POST → full render
app.tmpl · the line that flips the transport
<script defer src="{{lvtClientScriptURL}}"></script>
Validation

The HTML rule runs again in Go.

Both halves are in the app above. The input carries required, and ctx.ValidateForm() re-runs exactly that rule on the server — a client that skipped it, scripting off or a direct POST, gets the same answer. Then strings.EqualFold(name, "admin") adds the rule HTML has no way to state.

On the template side: {{.lvt.AriaInvalid "name"}} marks the field, {{.lvt.ErrorTag "name"}} is where the message lands. Returning an error from Greet is the whole mechanism — there's nothing to route. Scroll up and type admin, or try the smaller app here.

greet-validate · the same pair, on its own

Hello, there

on the wire · the rejected submit
{"action":"greet","data":{"name":"admin"}}
{"meta":{"errors":{"name":"\"admin\" is reserved"}}}
Pending state

Slow work has a pending state you can render.

These are the two borrowed apps, and both of them do slow work. Reach for the first one: the pending flag is a template variable, so the spinner is ordinary Go and ordinary HTML, with no new attribute to learn.

A · server-owned — template variables, no attributes
greet-async

Hello, there

<button {{if .lvt.Pending}}type="button" aria-busy="true"
  disabled{{else}}name="greet"{{end}}>Say hi</button>
lvt.Async(ctx,
    func(context.Context) (string, error) { return slowWork() },
    func(s State, name string, _ error) (State, error) {
        s.Name = name
        return s, nil
    },
)

No second action to wire up and no Loading field in state, though it does need a live session for the completion render.

B · the escape hatch, when the Go should not change
greet-loading

Hello, there

<button name="greet"
  lvt-el:addClass:on:pending="is-loading"
  lvt-el:removeClass:on:done="is-loading">Say hi</button>

Two lvt-* attributes, and the Go is untouched. This is what the escape hatch is for: the spinner is button chrome, not something the app models. It also works as a single request/response, where A needs a live session for its completion render.

Multi-user

Two calls sync your tabs. Changing the topic syncs everyone.

This is the part of Greet worth re-reading. ctx.SelfTopic() reaches your own tabs; "wall" reaches every visitor. Same two calls, different topic — that's the entire difference between "keeps my tabs in sync" and "multiplayer".

1 state changes 2 re-render 3 diff vs last render 4 patch the browser

The two cards below are separate sessions, like two different people. Greet in one and the line shows up on both walls — while the headlines stay independent.

visitor 1 · WebSocket on

Hello, there

the server said hi at 13:36:46

  • Hdhhd said hi 12:45:16
  • Hdhs said hi 12:45:20
  • jjijiji said hi 14:19:52
  • sex said hi 14:19:57
  • porn said hi 14:20:00
  • gay said hi 14:20:03
  • gay said hi 14:20:16
  • porn said hi 14:20:18
  • hello world said hi 15:57:39
  • Quarterly report said hi 16:08:36
  • 42 said hi 16:08:45
visitor 2 · WebSocket on

Hello, there

the server said hi at 13:36:46

  • Hdhhd said hi 12:45:16
  • Hdhs said hi 12:45:20
  • jjijiji said hi 14:19:52
  • sex said hi 14:19:57
  • porn said hi 14:20:00
  • gay said hi 14:20:03
  • gay said hi 14:20:16
  • porn said hi 14:20:18
  • hello world said hi 15:57:39
  • Quarterly report said hi 16:08:36
  • 42 said hi 16:08:45
app.go · the server can start the same cycle
sess.TriggerAction("ServerRefresh", nil)

You already read the WithTopicACL in main that admits "wall" — developer topics are deny-all until you name one. This is the same publish path with no user action behind it: the "the server said hi at …" line in the cards above, pushed on a timer.

How it compares

This sits between htmx and LiveView.

Server-rendered HTML over a socket, like LiveView. Ordinary form markup and no state in the browser, like htmx. Both of them are years more mature than this, and each row below says where the other one still wins.

htmx
Works against any backend in any language, which this does not. Pick it up when the server is already written. Here the server keeps the state and computes the diff, so the markup carries no request wiring.
templ + htmx
templ type-checks your markup at compile time. html/template cannot, and that is a real thing to give up. The trade is a code generation step and one more library to track.
Alpine.js
Keeps real state in the browser — expressions, loops, computed values — which nothing here does. An lvt-el: attribute toggles a class or an attribute on a DOM event and stops there. That covers a dropdown. It does not cover a widget with its own model.
Phoenix LiveView
The same idea, and years ahead of it: production use at scale, and an ecosystem this does not have. This is that idea in Go, on a library still in alpha.
React SPA
For a canvas editor or an offline-first app, a client framework is the right call. For a settings screen it means keeping two copies of the same data in sync, and that cost is the one thing this avoids.
More capabilities

Your app has file uploads and a login. Both are here.

Admin screens, internal tools, CRUD and dashboards are the point.

The UI patterns catalog has focused examples: loading states, inline validation, SPA-style navigation, sortable tables, pubsub, presence, server push. A LiveTemplate app renders every page on this site, including this one. See how it works.

Install it, then pick a recipe.

$ go get github.com/livetemplate/livetemplate

This is alpha: the core works and has tests, but the API may still change before v1.0.

The Go gopher