A complete tutorial for building a real-time chat application using LiveTemplate's simple kit. This demonstrates automatic multi-tab syncing, session management, and reactive UI updates with just 2 files.
All in just 2 files: main.go and chat.tmpl
cd examples/chat
GOWORK=off go run main.go
Then open http://localhost:8090 in multiple browser tabs to see automatic syncing in action:
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/, internal/, or database directories. Perfect for focused applications!
Open main.go and replace the counter example with chat state:
package main
import (
"log"
"net/http"
"os"
"sync"
"time"
"github.com/livetemplate/livetemplate"
)
type ChatState struct {
Messages []Message
Users map[string]*User
CurrentUser string
OnlineCount int
TotalMessages int
mu sync.RWMutex // Thread-safe access
}
type Message struct {
ID int
Username string
Text string
Timestamp string
}
type User struct {
Username string
JoinedAt time.Time
IsOnline bool
}
Key concepts:
ChatState struct holds all app statesync.RWMutex for thread-safe concurrent accessAdd the Change method to handle user actions:
func (s *ChatState) Change(ctx *livetemplate.ActionContext) error {
s.mu.Lock()
defer s.mu.Unlock()
switch ctx.Action {
case "send":
var data struct {
Message string `json:"message"`
}
if err := ctx.Bind(&data); err != nil {
return nil
}
if data.Message == "" {
return nil
}
s.TotalMessages++
msg := Message{
ID: s.TotalMessages,
Username: s.CurrentUser,
Text: data.Message,
Timestamp: time.Now().Format("15:04:05"),
}
s.Messages = append(s.Messages, msg)
return nil // Auto-syncs to all tabs in same browser!
case "join":
var data struct {
Username string `json:"username"`
}
if err := ctx.Bind(&data); err != nil {
return nil
}
s.CurrentUser = data.Username
if _, exists := s.Users[data.Username]; !exists {
s.Users[data.Username] = &User{
Username: data.Username,
JoinedAt: time.Now(),
IsOnline: true,
}
s.updateOnlineCount()
}
return nil
}
return nil
}
func (s *ChatState) updateOnlineCount() {
count := 0
for _, user := range s.Users {
if user.IsOnline {
count++
}
}
s.OnlineCount = count
}
Key concepts:
<form name="join"> and <form name="send"> (button/form name routing)ctx.GetString("field") extracts form dataAdd initialization and main function:
func (s *ChatState) Init() error {
if s.Users == nil {
s.Users = make(map[string]*User)
}
if s.Messages == nil {
s.Messages = []Message{}
}
return nil
}
func main() {
log.Println("chat starting...")
state := &ChatState{
Users: make(map[string]*User),
Messages: []Message{},
}
tmpl := livetemplate.New("chat", livetemplate.WithDevMode(true))
http.Handle("/", tmpl.Handle(state))
// Serve client library for development
http.HandleFunc("/livetemplate-client.js", serveClientLibrary)
port := os.Getenv("PORT")
if port == "" {
port = "8090"
}
log.Printf("🚀 Chat server starting on http://localhost:%s", port)
log.Println("📝 Open multiple browser tabs to test multi-user chat")
log.Println("💬 Messages are broadcast to all connected users")
http.ListenAndServe(":"+port, nil)
}
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 ----->| |
| [Auto-broadcast to group] |
|<---- update ------|------- update ----->|
| | |
The magic:
Traditional approach (what you DON'T need):
LiveTemplate simple kit:
html/templatenet/httpStore messages in a slice that survives restarts:
var persistedMessages []Message
func (s *ChatState) Init() error {
s.Messages = persistedMessages // Load from memory
// Or load from file: loadFromJSON("messages.json")
return nil
}
func (s *ChatState) Change(ctx *livetemplate.ActionContext) error {
// ... after adding message
persistedMessages = s.Messages // Save to memory
// Or save to file: saveToJSON("messages.json", s.Messages)
}
type ChatState struct {
// ... existing fields
TypingUsers map[string]bool
}
// In Change()
case "typing":
var data struct {
Username string `json:"username"`
}
ctx.Bind(&data)
s.TypingUsers[data.Username] = true
// Auto-broadcast!
type Message struct {
// ... existing fields
Reactions map[string]int // emoji -> count
}
case "react":
var data struct {
MessageID int `json:"messageId"`
Emoji string `json:"emoji"`
}
ctx.Bind(&data)
s.Messages[data.MessageID].Reactions[data.Emoji]++
type ChatState struct {
Rooms map[string]*Room
CurrentRoom string
}
type Room struct {
Name string
Messages []Message
}
In chat.tmpl:
<script src="https://unpkg.com/@livetemplate/client@latest/dist/livetemplate-client.browser.js"></script>
case "send":
if time.Since(s.LastMessageTime) < time.Second {
return nil // Too fast, ignore
}
// ... process message
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, Safari all see the same messages!
main.go + chat.tmplnet/http and html/templateThe simple kit starts with a counter. Here's how we evolved it:
| Counter Example | Chat Example |
|---|---|
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 pattern, different data!
counter example for a simpler starting pointtodos example for CRUD operationslvt new myapp --kit multi for apps needing databases