Your First App

You're going to build a counter. The plain version takes about 5 minutes. The fully reactive multi-tab version takes another 5. By the end you'll have seen every layer of the LiveTemplate model — and you'll have been clicking the same widget you wrote, embedded right in this page.

Prerequisite: Go 1.22 or later, and you've already run go get github.com/livetemplate/livetemplate in some directory.

Step 1 — Set up the project

mkdir counter && cd counter
go mod init counter
go get github.com/livetemplate/livetemplate

You'll have a go.mod and an empty directory. We'll add three files: counter.go (state and handlers), main.go (wiring), and counter.tmpl (the template).

Step 2 — Define the state and handlers

Create counter.go. First the state:

// CounterState is per-session state — pure data, cloned per session by
// livetemplate. AnonymousAuthenticator (handler.go) keeps state private
// per browser; BroadcastAction (below) keeps a single user's tabs in
// sync without leaking state to other visitors.
type CounterState struct {
	Counter int
}

State is a value type, not a pointer — controllers receive a copy and return a (possibly modified) copy. The framework manages the swap.

Then a controller and two action methods:

// CounterController holds shared dependencies (none in this demo) and
// exposes action methods invoked by name from the template.
type CounterController struct{}

// Increment is invoked when the user clicks the "+1" button. The
// runtime calls it with a clone of the current state and stores
// whatever you return. The BroadcastAction call tells the runtime
// to apply this same action on every other connected client, so
// multiple embeds and tabs stay in lockstep.
func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
	s.Counter++
	ctx.BroadcastAction("Increment", nil)
	return s, nil
}

// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
	s.Counter--
	ctx.BroadcastAction("Decrement", nil)
	return s, nil
}

Action methods are exported on the controller, and their names ARE the action names — Increment and Decrement are what the template will reference. The BroadcastAction calls are how multi-tab sync works (Step 6).

Now wire it up in main.go:

package main

import (
	"log"
	"net/http"

	"github.com/livetemplate/livetemplate"
)

func main() {
	tmpl := livetemplate.Must(livetemplate.New("counter",
		livetemplate.WithParseFiles("counter.tmpl"),
	))
	handler := tmpl.Handle(&CounterController{}, livetemplate.AsState(&CounterState{}))

	mux := http.NewServeMux()
	mux.Handle("/", handler)
	log.Fatal(http.ListenAndServe(":9090", mux))
}

livetemplate.New("counter") parses counter.tmpl from the same directory. tmpl.Handle(controller, AsState(initial)) is the standard wiring — controller for actions, initial state for new sessions.

By default LiveTemplate uses AnonymousAuthenticator, which gives each browser a stable session group via cookie. Two consequences worth knowing about now: each browser gets its own state (no cross-user leaks), and tabs from the same browser share state — that's what makes the broadcast demo at Step 6 work.

Step 3 — Write the template

Create counter.tmpl:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Counter</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/livetemplate.css">
    <script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
</head>
<body>
    <main class="container">
        <h1>Counter: {{.Counter}}</h1>
        <form method="POST" style="display:inline">
            <button name="increment">+1</button>
            <button name="decrement" class="secondary">-1</button>
        </form>
    </main>
</body>
</html>

The <button name="increment"> attribute is the routing trigger — clicking that button posts the form and the framework calls Increment() on the controller.

The two <link> and <script> tags in <head> load the LiveTemplate JS client; we'll see what they do at Step 5.

Step 4 — Run it

go run .

Open http://localhost:9090 in your browser to see your local counter. Or click +1 and -1 right here — the same source files, served by this docs site, running below:

Counter: 0

Click and the count changes — no full-page reload, just a DOM patch streamed over WebSocket. That's the JS client at work.

Step 5 — Tier 1: it works without JavaScript

Remove these two lines from the template:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/livetemplate.css">
<script defer src="https://cdn.jsdelivr.net/npm/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>

…and the counter still works. Each click does a full form POST and page reload (you'll see a brief flash). The framework re-renders. The browser navigates. No JavaScript needed.

This is LiveTemplate's Tier 1: forms POST, server re-renders, browser navigates. Add the JS client back (the two CDN lines) and the framework opens a WebSocket — your click sends a frame instead of a form POST, the server diffs the new render against the previous, and only the changed text node (Counter: 1Counter: 2) is sent back as a patch.

Same Go code. Same template. Two lines of HTML promote the experience from server-rendered-with-reload to in-place reactive.

Step 6 — Multi-tab sync (broadcast)

Look at the handlers from Step 2 — note the highlighted lines:

func (c *CounterController) Increment(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
	s.Counter++
	ctx.BroadcastAction("Increment", nil)
	return s, nil
}

// Decrement follows the same pattern.
func (c *CounterController) Decrement(s CounterState, ctx *livetemplate.Context) (CounterState, error) {
	s.Counter--
	ctx.BroadcastAction("Decrement", nil)
	return s, nil
}

ctx.BroadcastAction("Increment", nil) (and the matching Decrement) tells LiveTemplate to apply the same action on every other connection in the same session group — multiple tabs and embeds within your browser. Without it, each tab has its own count; with it, they stay in lockstep.

To prove it, here are two embeds against the same counter, side by side:

Counter: 0

Counter: 0

Click +1 in one — watch the other update in real time. They're talking to the same upstream session, and BroadcastAction is what makes them stay synced. (On a narrow viewport the embeds stack vertically — the broadcast still works.)

Why does this stay scoped to your browser? LiveTemplate's default authenticator (AnonymousAuthenticator) uses a cookie to assign each browser a stable session group. Tabs from the same browser share that group — that's why the two embeds above sync. Different browsers — or an incognito window in the same browser — get different cookies, different groups, and isolated state. For a public docs site this is the right default: every visitor gets a clean slate, and the broadcast demo still proves the feature within their own browser. See Recipes/Counter, deeper for the full session-group + scaling story.

What you just built

You wrote a counter that:

…in about 50 lines of Go and HTML, with no build step, no client-side framework, no custom template language. The two embeds above? They're the same code rendered live. Every click you've done has gone through your handler, broadcast across, and patched the DOM.

What next?