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.
- Real-time messaging with automatic tab syncing
- User login and presence tracking
- Instant UI updates across all tabs in the same browser
- Browser session isolation (each browser has its own chat room)
- Message history and timestamps
All in just 2 files: main.go and chat.tmpl
cd examples/chat
GOWORK=off go run main.goThen open http://localhost:8090 in multiple browser tabs to see automatic syncing in action:
- Messages sent in one tab appear instantly in all other tabs
- Each browser gets its own isolated chat session
Start by creating a new LiveTemplate application with the simple kit:
lvt new chat --kit simple
cd chatThe simple kit generates a minimal structure:
main.go- Application logic (single file)chat.tmpl- HTML template (single file)go.mod- Go module configurationREADME.md- Documentation
No 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:
- Single
ChatStatestruct holds all app state sync.RWMutexfor thread-safe concurrent access- Simple Go structs - no database, no ORM, no complexity
Add 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:
- Actions route via
<form name="join">and<form name="send">(button/formnamerouting) ctx.GetString("field")extracts form data- Just modify state - broadcasting happens automatically!
- No manual WebSocket code needed
Add 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.goOpen http://localhost:8090 in multiple browser tabs:
Test 1 - Same browser, multiple tabs:
- Open 2+ tabs in Chrome
- Login with any username in tab 1
- Send a message in tab 1
- It appears instantly in tab 2! ✨
- Try sending from tab 2 - appears in tab 1
Test 2 - Different browsers (isolated sessions):
- Open Chrome and Firefox
- Each browser gets its own chat room
- Messages in Chrome don't appear in Firefox
- Each browser maintains separate state
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:
- Each browser gets a unique session ID (stored in cookie)
- All tabs in the same browser share the session ID
- State changes automatically sync to all tabs in the same session
- Only changed HTML is sent (tree-diffing)
- Zero manual broadcasting code required!
Traditional approach (what you DON'T need):
- ❌ Manual WebSocket management
- ❌ Database setup
- ❌ ORM configuration
- ❌ Complex directory structure
- ❌ Separate frontend/backend
- ❌ API endpoints
- ❌ State sync logic
LiveTemplate simple kit:
- ✅ Just modify Go structs
- ✅ 2 files total
- ✅ Auto-broadcasting
- ✅ Auto-updates
- ✅ Standard
html/template - ✅ Standard
net/http
Store 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 messageif 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!
- Two files - That's it!
main.go+chat.tmpl - Zero boilerplate - No cmd/, internal/, database/
- Auto-syncing - Tabs stay in sync automatically
- Standard Go - Uses
net/httpandhtml/template - Type-safe - Go structs, no JSON marshaling needed
- Efficient - Tree-diffing sends only changes
The 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!
- Try the
counterexample for a simpler starting point - Try the
todosexample for CRUD operations - Use
lvt new myapp --kit multifor apps needing databases