A tutorial for building a real-time chat room on LiveTemplate's simple kit: multi-tab sync, session management and reactive UI updates, in two files.
Two files: main.go and chat.tmpl. Every snippet here comes straight out of
examples/chat/main.go, so it cannot drift from the app you are running.
cd examples/chat
GOWORK=off go run main.go
Then open http://localhost:8090 in two or more browser tabs:
Start by creating a new LiveTemplate application with the simple kit:
lvt new chat --kit simple
cd chat
The simple kit generates a minimal structure:
main.go - Application logic (single file)chat.tmpl - HTML template (single file)go.mod - Go module configurationREADME.md - DocumentationNo cmd/, no internal/, no database. A larger app will grow some of those; a
chat room this size doesn't need them.
Two types, and the split between them is the thing to get right.
The controller is a singleton. It holds what every tab must agree on — the message list, who is online — behind a mutex.
The state is per connection. Each tab gets its own copy, which is why
CurrentUser can differ between two tabs of the same browser.
type ChatController struct {
mu sync.RWMutex
messages []Message
users map[string]bool // username → online
totalMessages int
}
// ChatState is per-connection — each tab gets its own independent copy.
// CurrentUser is never shared across tabs.
type ChatState struct {
Messages []Message `json:"messages"`
CurrentUser string `json:"current_user" lvt:"persist"`
OnlineCount int `json:"online_count"`
TotalMessages int `json:"total_messages"`
}
type Message struct {
ID int `json:"id"`
Username string `json:"username"`
Text string `json:"text"`
Timestamp string `json:"timestamp"`
}
Put dependencies on the controller and serializable UI data in the state. Put a mutex on the state struct and each connection gets its own copy, guarding nothing.
Mount runs once per session group. It opts this connection into its own topic
and seeds the state from the controller:
func (c *ChatController) Mount(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
c.mu.RLock()
defer c.mu.RUnlock()
state.Messages = c.copyMessages()
state.TotalMessages = c.totalMessages
state.OnlineCount = c.countOnline()
return state, nil
}
Send appends to the shared list, then tells the peers:
func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
text := ctx.GetString("message")
if text == "" || state.CurrentUser == "" {
return state, nil
}
c.mu.Lock()
c.totalMessages++
msg := Message{
ID: c.totalMessages,
Username: state.CurrentUser,
Text: text,
Timestamp: time.Now().Format("15:04:05"),
}
c.messages = append(c.messages, msg)
state.Messages = c.copyMessages()
state.TotalMessages = c.totalMessages
c.mu.Unlock()
// Tell other tabs about the new message
if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil {
return state, err
}
return state, nil
}
Publish does not push state. It runs a named action — here NewMessage — on
every other subscribed connection, and that action rebuilds its own tab's view:
func (c *ChatController) NewMessage(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.RLock()
defer c.mu.RUnlock()
state.Messages = c.copyMessages()
state.TotalMessages = c.totalMessages
return state, nil
}
You need both. Without the Subscribe in Mount the publish reaches nobody;
without the Publish no peer ever runs. Neither happens on its own.
OnConnect fires per WebSocket rather than per session group, which is how a
second tab starts logged out instead of inheriting the first tab's user:
func (c *ChatController) OnConnect(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.RLock()
defer c.mu.RUnlock()
state.CurrentUser = ""
state.Messages = c.copyMessages()
state.TotalMessages = c.totalMessages
state.OnlineCount = c.countOnline()
return state, nil
}
func main() {
log.Println("chat starting...")
envConfig, err := livetemplate.LoadEnvConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
if err := envConfig.Validate(); err != nil {
log.Fatalf("Invalid configuration: %v", err)
}
controller := &ChatController{
users: make(map[string]bool),
}
initialState := &ChatState{}
tmpl := livetemplate.Must(livetemplate.New("chat", envConfig.ToOptions()...))
http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))
Handle takes the controller and the initial state. Method names are the action
names, so <button name="send"> reaches Send with nothing to register.
Replace chat.tmpl with the chat interface. Key template concepts:
Conditional Rendering:
{{if not .CurrentUser}}
<!-- Show login form -->
{{else}}
<!-- Show chat interface -->
{{end}}
Message Loop:
{{range .Messages}}
<div class="message {{if eq .Username $.CurrentUser}}mine{{end}}">
<div class="message-header">
<span class="message-username">{{.Username}}</span>
<span class="message-time">{{.Timestamp}}</span>
</div>
<div class="message-text">{{.Text}}</div>
</div>
{{end}}
Form Actions:
<form method="POST" name="join">
<input type="text" name="username" required autofocus>
<button type="submit">Join Chat</button>
</form>
<form method="POST" name="send">
<input type="text" name="message" autocomplete="off">
<button type="submit">Send</button>
</form>
Auto-scroll Script:
<script>
{{if .CurrentUser}}
function scrollToBottom() {
const messages = document.getElementById('messages');
if (messages) {
messages.scrollTop = messages.scrollHeight;
}
}
scrollToBottom();
if (window.LiveTemplate) {
const originalUpdate = window.LiveTemplate.prototype.updateDOM;
window.LiveTemplate.prototype.updateDOM = function(...args) {
originalUpdate.apply(this, args);
setTimeout(scrollToBottom, 50);
};
}
{{end}}
</script>
go run main.go
Open http://localhost:8090 in multiple browser tabs:
Test 1 - Same browser, multiple tabs:
Test 2 - Different browsers (isolated sessions):
Chrome Tab 1 Server (Go) Chrome Tab 2
| | |
|---- join -------->| |
| [groupID: session-abc] |
| |<------ join --------|
| [Same groupID: session-abc] |
| | |
|--- send msg ----->| |
| [Send appends, then Publish's |
| NewMessage runs on tab 2] |
|<---- update ------|------- update ----->|
| | |
What happens, in order:
Mount subscribes the connection to ctx.SelfTopic() — the topic scoped to
that session group.Send calls ctx.Publish(ctx.SelfTopic(), "NewMessage", nil), which
runs NewMessage on the other subscribed tabs.Both halves are explicit. main.go has one Subscribe and three Publish
calls, and they are the entire sync mechanism — nothing fans out on its own.
You don't manage the WebSocket, and there are no API endpoints to define. The server re-renders the template and sends the diff, so there's no client-side copy of the message list to keep in step.
What you do write is on this page: two structs, the action methods, and the
Subscribe/Publish pair above.
Messages live on the controller and die with the process. Give the controller a
store and read it in Mount:
type ChatController struct {
mu sync.RWMutex
store MessageStore // your database, file, whatever
users map[string]bool
}
func (c *ChatController) Mount(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
return state, err
}
msgs, err := c.store.Recent(ctx.Request().Context(), 50)
if err != nil {
return state, err
}
state.Messages = msgs
return state, nil
}
The store belongs on the controller, not the state — every connection clones the state and round-trips it through JSON.
A new action method and a publish, the same shape as Send:
func (c *ChatController) Typing(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.Lock()
c.typing[state.CurrentUser] = time.Now()
c.mu.Unlock()
return state, ctx.Publish(ctx.SelfTopic(), "TypingChanged", nil)
}
func (c *ChatController) TypingChanged(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.RLock()
defer c.mu.RUnlock()
state.TypingUsers = c.activeTypers()
return state, nil
}
The button carries the id and the emoji; the server reads them off the form:
// <button name="react" value="{{.ID}}"> with a hidden emoji input
func (c *ChatController) React(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
id, emoji := ctx.GetInt("value"), ctx.GetString("emoji")
c.mu.Lock()
c.reactions[id][emoji]++
c.mu.Unlock()
return state, ctx.Publish(ctx.SelfTopic(), "NewMessage", nil)
}
A room is a topic. Subscribe to the room instead of SelfTopic() and every
member of that room receives the publish — including other people, which
SelfTopic() deliberately will not do:
func (c *ChatController) JoinRoom(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
state.CurrentRoom = ctx.GetString("room")
return state, ctx.Subscribe("room:" + state.CurrentRoom)
}
Developer topics are deny-all until named, so a room app also needs
WithTopicACL in main. See Pubsub.
In chat.tmpl — the framework function renders the pinned CDN URL for the
client this server release is wire-compatible with, so the two stay in
lockstep:
<link rel="stylesheet" href="{{lvtClientStyleURL}}">
<script defer src="{{lvtClientScriptURL}}"></script>
Keep the clock on the controller, keyed by session group — a per-connection field is trivially reset by opening a new tab:
func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatState, error) {
c.mu.Lock()
last := c.lastSend[ctx.GroupID()]
if time.Since(last) < time.Second {
c.mu.Unlock()
return state, nil
}
c.lastSend[ctx.GroupID()] = time.Now()
c.mu.Unlock()
// ... append and publish
}
if len(s.Messages) > 100 {
s.Messages = s.Messages[len(s.Messages)-100:] // Keep last 100
}
For production, use real auth instead of just username:
auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) {
return validateUser(username, password)
})
tmpl := livetemplate.New("chat",
livetemplate.WithDevMode(false),
livetemplate.WithAuthenticator(auth),
)
By default, each browser has its own isolated chat. To make all users share the same chat room:
// Custom authenticator that puts everyone in same session group
type GlobalChatAuthenticator struct{}
func (a *GlobalChatAuthenticator) Identify(r *http.Request) (string, error) {
return "", nil // Anonymous
}
func (a *GlobalChatAuthenticator) GetSessionGroup(r *http.Request, userID string) (string, error) {
return "global-chat-room", nil // Everyone shares same group!
}
// Use it:
tmpl := livetemplate.New("chat",
livetemplate.WithDevMode(true),
livetemplate.WithAuthenticator(&GlobalChatAuthenticator{}),
)
Now Chrome, Firefox and Safari share one room instead of getting one each.
Two files: main.go and chat.tmpl. Messages are Go structs, so there's no JSON
marshaling between the server and the template. Tabs stay in step through the
Subscribe/Publish pair, and the server sends only the parts of the page that
changed.
What it doesn't show is persistence. Messages live in a slice on the controller and are gone when the process restarts — see Add Persistence.
The counter is the smaller version of the same shape:
| Counter | Chat |
|---|---|
AppState{Counter int} |
ChatState{Messages []Message} |
increment/decrement actions |
join/send actions |
| Single user | Multi-user with broadcasting |
| Simple int update | List of messages |
Same shape, different data.
counter example for a simpler starting pointtodos example for CRUD operationslvt new myapp --kit multi for apps needing databases