The counter recipe ends at "click +1, see number." That's enough to prove the framework runs. It's not enough to know what a real LiveTemplate app looks like — what auth wires up, where state lives, how reusable components compose with the framework's render lifecycle.
This recipe is the smallest app that touches all of those: a multi-user todo list with BasicAuth scoping, SQLite persistence, and the lvt/components modal + toast for the finishing touches.
The live demo can't embed inline — it's gated by HTTP BasicAuth, which is part of the teaching point. Open it in a new tab and the browser will prompt for credentials:
→ Launch the live demo · alice / password or bob / password
Add a few todos, then open the same link in an incognito window and log in as the other user — your two browsers see two separate lists, even though they're talking to the same Go process. That separation is the recipe's central teaching point.
The handler ties together five things: BasicAuth, the in-memory database, the controller, the components, and the template. The whole orchestration is one function:
// per process.
func Handler(opts ...livetemplate.Option) http.Handler {
handlerOnce.Do(func() {
queries, err := InitDB(":memory:")
if err != nil {
log.Fatalf("todos: init DB: %v", err)
}
controller := &TodoController{Queries: queries}
auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) {
users := map[string]string{
"alice": "password",
"bob": "password",
}
pass, ok := users[username]
return ok && pass == password, nil
})
componentSets := []*base.TemplateSet{
modal.Templates(),
toast.Templates(),
}
ltSets := make([]*livetemplate.TemplateSet, len(componentSets))
for i, set := range componentSets {
ltSets[i] = convertTemplateSet(set)
}
baseOpts := []livetemplate.Option{
livetemplate.WithParseFiles(extractTemplate()),
livetemplate.WithAuthenticator(auth),
livetemplate.WithComponentTemplates(ltSets...),
}
baseOpts = append(baseOpts, opts...)
tmpl := livetemplate.Must(livetemplate.New("todos", baseOpts...))
initialState := &TodoState{
Title: "Todo App",
CurrentPage: DefaultPage,
PageSize: DefaultPageSize,
LastUpdated: formatTime(),
}
mux := http.NewServeMux()
mux.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))
mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS)
rootHandler = mux
})
return rootHandler
}
Three of those livetemplate.With* options carry teaching weight. Origins (opts...) are deployment plumbing.
BasicAuthenticator scopes data per userEvery action handler in the controller filters database queries by ctx.UserID(). That UserID comes from the authenticator: BasicAuthenticator returns the username that authenticated the request as both the user identity and the session group ID. The framework guarantees:
Sync() keeps tabs in sync within one logged-in userThe query layer enforces this at the SQL boundary too — every query takes a user_id parameter:
-- name: GetAllTodos :many
SELECT * FROM todos
WHERE user_id = ?
ORDER BY created_at DESC;
-- name: GetTodoByID :one
SELECT * FROM todos
WHERE id = ? AND user_id = ?
LIMIT 1;
-- name: CreateTodo :one
INSERT INTO todos (id, user_id, text, completed, created_at)
VALUES (?, ?, ?, ?, ?)
RETURNING *;
A bug in the controller that forgot to pass ctx.UserID() would silently return all users' todos. The compile-time signature on the generated GetAllTodos(ctx, userID) makes that mistake hard to write — and a real production app would also enforce it at the DB layer with a row-level security policy. Here :memory: SQLite skips that, but the column is laid down so the upgrade path is one-line.
lvt:"persist"The trick most LiveTemplate apps hit on day three is that state objects must round-trip through JSON serialization on reconnect — but rich UI primitives (modal stacks, toast queues) carry mutable state and aren't serializable. Components solve this by being re-initialized on every state-restoring lifecycle method.
Look at the state struct:
type TodoState struct {
// Display metadata
Title string `json:"title" lvt:"persist"`
Username string `json:"username" lvt:"persist"`
LastUpdated string `json:"last_updated"`
// Filter and sort settings
SearchQuery string `json:"search_query" lvt:"persist"`
SortBy string `json:"sort_by" lvt:"persist"`
// Todo data
FilteredTodos []TodoItem `json:"filtered_todos"` // After search filter applied
PaginatedTodos []TodoItem `json:"paginated_todos"` // Current page slice
// Statistics
TotalCount int `json:"total_count"`
CompletedCount int `json:"completed_count"`
RemainingCount int `json:"remaining_count"`
// Pagination state
CurrentPage int `json:"current_page" lvt:"persist"`
PageSize int `json:"page_size" lvt:"persist"`
TotalPages int `json:"total_pages"`
ShowPagination bool `json:"show_pagination"`
PrevDisabled bool `json:"prev_disabled"`
NextDisabled bool `json:"next_disabled"`
// Component state (non-persistent, re-initialized in Mount)
Toasts *toast.Container
DeleteConfirm *modal.ConfirmModal
DeleteID string `json:"delete_id" lvt:"persist"`
}
Toasts and DeleteConfirm are pointer types from lvt/components. They're missing the lvt:"persist" tag deliberately — when a connection reconnects mid-conversation, the framework rehydrates everything else (the search query, the page number, the pending delete ID) but leaves these nil. The controller re-creates them in three places — every entry point where state may have just been hydrated:
func (c *TodoController) Mount(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.Username = ctx.UserID()
state = initComponents(state)
return c.loadTodos(context.Background(), state, ctx.UserID())
}
func (c *TodoController) OnConnect(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.Username = ctx.UserID()
state = initComponents(state)
return c.loadTodos(context.Background(), state, ctx.UserID())
}
func (c *TodoController) Sync(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state = initComponents(state)
return c.loadTodos(context.Background(), state, ctx.UserID())
}
And the re-init function:
state = applyPagination(state)
return state, nil
}
// initComponents initializes non-serializable component objects.
// Called from Mount/OnConnect/Sync since components can't survive serialization.
func initComponents(state TodoState) TodoState {
if state.Toasts == nil {
toasts := toast.New("notifications",
toast.WithPosition(toast.TopRight),
toast.WithMaxVisible(3),
)
toasts.SetStyled(false)
state.Toasts = toasts
}
if state.DeleteConfirm == nil {
state.DeleteConfirm = modal.NewConfirm("delete_confirm",
modal.WithConfirmTitle("Delete Todo"),
modal.WithConfirmMessage("Are you sure you want to delete this todo?"),
modal.WithConfirmDestructive(true),
modal.WithConfirmText("Delete"),
The pattern: persistable plain data with lvt:"persist"; non-serializable runtime objects re-built in Mount / OnConnect / Sync. A toast queue that was serialized would be a re-render hazard (the same notifications would repaint after every reconnect); explicit re-init at lifecycle entry points is the right shape.
The delete-with-confirm flow is two action handlers and the modal component handles the open/close state for you:
// ConfirmDelete shows the delete confirmation modal for the given todo ID.
func (c *TodoController) ConfirmDelete(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.DeleteID = ctx.GetString("id")
state.DeleteConfirm.Show()
return state, nil
}
// ConfirmDeleteConfirm executes the deletion after the user confirms the modal.
func (c *TodoController) ConfirmDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
if state.DeleteID == "" {
state.DeleteConfirm.Hide()
return state, nil
}
dbCtx := context.Background()
err := c.Queries.DeleteTodo(dbCtx, db.DeleteTodoParams{
ID: state.DeleteID,
UserID: ctx.UserID(),
})
if err != nil {
return state, fmt.Errorf("failed to delete todo: %w", err)
}
state.Toasts.AddSuccess("Deleted", "Todo removed")
state.DeleteConfirm.Hide()
state.DeleteID = ""
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
}
// CancelDeleteConfirm dismisses the delete confirmation modal.
func (c *TodoController) CancelDeleteConfirm(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
state.DeleteConfirm.Hide()
state.DeleteID = ""
return state, nil
}
ConfirmDelete is fired when the user clicks Delete on a row — the modal is opened, no DB work yet. ConfirmDeleteConfirm runs only if the user clicks the destructive button inside the modal — by then state.DeleteID is whatever the original click captured. CancelDeleteConfirm clears the modal without touching the DB. The component never round-trips to the server for its own UI state changes; it's just state.DeleteConfirm.Show() / .Hide().
Toasts are even simpler — fire-and-forget from any action:
func (c *TodoController) Add(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
var input AddInput
if err := ctx.BindAndValidate(&input, validate); err != nil {
return state, err
}
now := time.Now()
id := fmt.Sprintf("todo-%d", now.UnixNano())
dbCtx := context.Background()
_, err := c.Queries.CreateTodo(dbCtx, db.CreateTodoParams{
ID: id,
UserID: ctx.UserID(),
Text: input.Text,
Completed: false,
CreatedAt: now,
})
if err != nil {
return state, fmt.Errorf("failed to create todo: %w", err)
}
state.Toasts.AddSuccess("Added", fmt.Sprintf("%q added", input.Text))
state.LastUpdated = formatTime()
return c.loadTodos(dbCtx, state, ctx.UserID())
}
The state.Toasts.AddSuccess(...) call queues a notification; the rendered template walks state.Toasts and emits the toast container. The toast disappears client-side on its own dismiss timer; you don't write any of that.
This is the smallest app that exercises the full LiveTemplate idiom. A real production app would extend it on three axes:
| Concern | This recipe | Production shape |
|---|---|---|
| Persistence | :memory: SQLite, lost on restart |
File-backed SQLite or Postgres; daily backup |
| Auth | Hardcoded alice/bob with plaintext passwords | OAuth/SSO + an Authenticator impl that validates session tokens |
| Multi-instance broadcast | Single Fly machine | WithPubSubBroadcaster (Redis) so Sync() reaches peer instances |
| User registration | None | Companion endpoint + lvt/components/form validation |
| Audit trail | None | Append-only log table; query layer logs writes |
None of those changes the recipe's shape — the same controller methods, the same state struct, the same components. They swap implementations, not the surface. That's the architectural payoff for the upfront ceremony of Authenticator + Mount/OnConnect/Sync + components: the apps that grow out of this recipe inherit a clean separation between deployment plumbing and the actual interaction surface.
BroadcastAction mechanism this app uses for multi-tab Sync(), in isolation.Authenticator interface and the contracts BasicAuthenticator implements.lvt/components.Sync() is enough and when you need explicit broadcast.