Todos: a real LiveTemplate app

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.

Anatomy of the wiring

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
}

handler.go:87-138

Three of those livetemplate.With* options carry teaching weight. Origins (opts...) are deployment plumbing.

How BasicAuthenticator scopes data per user

Every 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:

The 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 *;

queries.sql:1-15

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.

Why components live outside 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"`
}

state.go:63-94

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())
}

controller.go:19-34

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"),

controller.go:245-266

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
}

controller.go:96-131

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())
}

controller.go:36-60

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.

Where the recipe stops, and what production needs

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.

What next?