Progressive Enhancement Todo List
This app works with or without JavaScript enabled
Most "live" frameworks have a load-bearing assumption: JavaScript is on, the WebSocket connects, and the user agent cooperates. LiveTemplate is built so that those assumptions can fail one at a time without the app breaking. The same controller, the same template, and the same form markup degrade through three modes:
WithWebSocketDisabled()). The client falls back to plain HTTP fetch(); same diff patches over a different transport.303 See Other (POST-Redirect-GET).The interesting bit is that the template doesn't change between tiers — every form is <form method="POST" name="add">, every button is <button name="add">. Tier C falls out of native browser behavior, the framework just has to handle the POST. The only code-level distinction across the three modes is one option flag for Tier B.
The live demo below uses LiveTemplate's normal transport: a WebSocket per session, diff patches over the wire, no whole-page reload between actions. Add a todo, toggle one, delete one — all without a navigation event.
The handler that produces this is the smallest possible recipe shape — no auth, no DB:
func Handler(opts ...livetemplate.Option) http.Handler {
controller := &TodoController{validate: validator.New()}
initialState := &TodoState{}
baseOpts := []livetemplate.Option{
livetemplate.WithParseFiles(extractTemplate()),
}
baseOpts = append(baseOpts, opts...)
tmpl := livetemplate.Must(livetemplate.New("progressive-enhancement", baseOpts...))
mux := http.NewServeMux()
mux.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState)))
mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary)
mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS)
return mux
That's it. WithParseFiles(extractTemplate()) ships the embedded .tmpl into the framework; everything else (opts...) is what the caller — cmd/site in production, the e2e harness in tests — supplies for origin policy and dev-mode static assets.
Same app, same template, same controller. The only difference is one option appended at construction time:
livetemplate.WithWebSocketDisabled()
When the server rejects the WebSocket upgrade, the client library detects it, falls back to plain HTTP fetch() for action delivery, and applies the same diff patches it would have applied over WS. The user-visible behavior is identical — instant updates, no page reload — but the network path is request/response.
A WebSocket upgrade against this mount is rejected before negotiation:
GET /no-ws/ HTTP/1.1
Upgrade: websocket
Connection: Upgrade
→ HTTP/1.1 400 Bad Request (or similar non-101)
The e2e suite asserts this directly with TestPE_TierB_WebSocketRejected. When you see a 101 from this mount, something is wrong.
Tier C is the one that surprises people new to LiveTemplate, because there's no toggle for it on the server side — it's just what happens when the JS client isn't there to intercept the form submit. The browser sends POST with Accept: text/html to the form's action (the page's own URL), the framework handles the action, and the response is a 303 See Other to the same URL with the flash message stashed in a cookie:
POST / HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Accept: text/html
add=&title=Pick+up+milk
→ HTTP/1.1 303 See Other
Location: /
Set-Cookie: lvt-flash=success=Added%3A+Pick+up+milk; ...
The browser follows the redirect, the next GET re-renders with the new state, and the flash cookie is consumed and cleared. POST-Redirect-GET is a well-known pattern; LiveTemplate just speaks it natively when the request shape says "no JS interception."
The template carries one piece of UX scaffolding for this mode — a <noscript> banner that's only visible when scripts are disabled:
<!-- region:noscript-banner -->
<noscript>
<mark>
<strong>No JavaScript Mode:</strong> Using traditional HTTP form submissions with page reloads.
Each action reloads the page to show updates.
</mark>
</noscript>
progressive-enhancement.tmpl:27-33
To try Tier C live: open the Tier A demo in a new tab, then in DevTools (Cmd-Option-I / F12) → Settings → Debugger, check "Disable JavaScript" and refresh. The banner appears, every action causes a full page navigation, but the app remains fully functional.
InputTitle is on the state structForms reset on submit. If the user types ab (too short), submits, and gets a validation error, the framework re-renders — and on Tier A/B the framework's diff doesn't reset the input field, but on Tier C the page is fully reloaded after a 303 round-trip and the input is gone. Without explicit handling, the user retypes from scratch.
The fix is one persisted field on state and one template binding:
title := strings.TrimSpace(input.Title)
newID := fmt.Sprintf("%d", time.Now().UnixNano())
state.Items = append(state.Items, Todo{
ID: newID,
Title: title,
Completed: false,
CreatedAt: formatTime(),
})
state.InputTitle = ""
ctx.SetFlash("success", fmt.Sprintf("Added: %s", title))
return state, nil
}
// Toggle flips a todo's completed status by ID.
func (c *TodoController) Toggle(state TodoState, ctx *livetemplate.Context) (TodoState, error) {
id := ctx.GetString("id")
found := false
for i := range state.Items {
if state.Items[i].ID == id {
On validation failure, state.InputTitle = ctx.GetString("title") captures whatever the user typed; the template binds it back via value="{{.InputTitle}}". After a successful submit, state.InputTitle = "" clears it. The same persistence works across all three tiers because state round-trips through the framework regardless of transport.
All three forms in the template use the same shape:
{{end}}
<!-- region:add-form -->
<form method="POST" name="add">
<fieldset role="group">
<input
type="text"
name="title"
value="{{.InputTitle}}"
placeholder="What needs to be done?"
{{.lvt.AriaInvalid "title"}}
autofocus
>
<button type="submit" name="add" {{.lvt.AriaDisabled "title"}}>Add</button>
progressive-enhancement.tmpl:41-54
The form has name="add" and a button with name="add". Both naming the same action is intentional belt-and-suspenders:
name=value pair is included, so the body is add=&title=... — the framework reads add= from the body to route the action.name attribute as the action.Either path resolves to the controller's Add method. The toggle and delete forms follow the same shape with hidden id inputs to carry the row identity.
Three tiers from one controller is a lot of mileage from one option flag, but there are real limits:
<form method="POST"> for every action. Pure-button no-form interactions (e.g., <button onclick="...">) skip the browser's form-encoding step and have no Tier C path.Change() is JS-only. Live debounced input updates on every keystroke can't happen without JS — Tier C readers won't see incremental feedback. The submit-on-blur fallback is what they get.BroadcastAction to other connections needs a transport channel; HTTP fetch is request-scoped. Tier B users see their own updates, not peers'.These are the cliffs. For the 80% of CRUD forms that don't need any of them, the recipe shape — one controller, three transports, one option flag — covers all three tiers without conditionals.