Documentation / Build a live guestbook
Build a live guestbook
Build one small app end to end. A page, a form that validates on the server, htmx swaps, and new entries that appear in every open browser.
What you will build
A guestbook in one Go file and two templates. Visitors read the entries, sign with a name and a message, and see a validation message beside the field they got wrong. When someone signs, every open browser shows the new entry without a reload.
Along the way you use the pieces most HyperChi apps share:
- a context handler that renders a page with
c.View hyperchi.Bindto decode and validate a formhyperchi.FieldErrorsand a 422 fragment to show what was wrongapp.WebSocketandapp.Roomto push HTML to everyone who is watching
You need Go 1.26 or later and nothing else: no Node, no C compiler, no database.
mkdir guestbook
cd guestbook
go mod init example.com/guestbook
go get github.com/regiellis/hyperchi
mkdir -p templates/layouts templates/pages
The layout
Every page renders inside layout-base, which receives the page as .content. Load htmx 4.0.0 and the WebSocket extension, which HyperChi serves at /hyperchi/js/. Save this as templates/layouts/base.tmpl:
{{define "layout-base"}}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guestbook</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@4.0.0/dist/htmx.min.js"></script>
<script src="/hyperchi/js/hx-ws.js"></script>
</head>
<body>
{{.content}}
</body>
</html>{{end}}
The page and its fragments
One file can define several templates. guestbook is the page. entry and guestbook-form are fragments: the page includes them, and handlers also render them on their own for htmx. Save this as templates/pages/guestbook.tmpl:
{{define "guestbook"}}
<main>
<h1>Guestbook</h1>
<ul id="entries" {{wsConnect "/live"}}>
{{range .entries}}{{template "entry" .}}{{end}}
</ul>
{{template "guestbook-form" .}}
</main>
{{end}}
{{define "entry"}}
<li><strong>{{.Name}}</strong>: {{.Message}} <small>{{.At.Format "15:04"}}</small></li>
{{end}}
{{define "guestbook-form"}}
<form hx-post="/sign" hx-swap="outerHTML">
<label>Name <input name="name" value="{{with .in}}{{.Name}}{{end}}"></label>
{{range .errors.name}}<p class="error">Name {{.}}.</p>{{end}}
<label>Message <textarea name="message">{{with .in}}{{.Message}}{{end}}</textarea></label>
{{range .errors.message}}<p class="error">Message {{.}}.</p>{{end}}
<button>Sign</button>
</form>
{{end}}
Three details matter here:
{{wsConnect "/live"}}writes thehx-ws:connectattribute, escaped. The list connects to the server's socket when the page loads.- The
entryfragment reads anEntry's fields. Inside the page'srange, dot is the entry itself; when a handler or a broadcast rendersentrywith anEntryvalue, HyperChi exposes the struct's exported fields as top-level keys. So one template serves the page, the htmx response, and the broadcast. - The form swaps itself (
hx-swap="outerHTML"). The server answers with a fresh form after a success, or the same form with its errors after a failure.
The server
Save this as main.go:
package main
import (
"log"
"slices"
"sync"
"time"
"github.com/regiellis/hyperchi/hyperchi"
)
// Entry is one signature in the guestbook.
type Entry struct {
Name string
Message string
At time.Time
}
// Guestbook keeps entries in memory, safe for concurrent requests.
type Guestbook struct {
mu sync.Mutex
entries []Entry
}
func (g *Guestbook) Add(e Entry) {
g.mu.Lock()
defer g.mu.Unlock()
g.entries = append(g.entries, e)
}
func (g *Guestbook) All() []Entry {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Clone(g.entries)
}
// Signing is what the form posts. Bind decodes and validates it.
type Signing struct {
Name string `form:"name" validate:"required,max=40"`
Message string `form:"message" validate:"required,max=280"`
}
func main() {
app := hyperchi.New()
if err := app.LoadTemplates("templates/**/*.tmpl"); err != nil {
log.Fatal(err)
}
app.Extensions().EnableExtensionAssets()
book := &Guestbook{}
app.Get("/", func(c *hyperchi.Context) error {
return c.View("guestbook", hyperchi.H{"entries": book.All()})
})
app.Post("/sign", func(c *hyperchi.Context) error {
in, err := hyperchi.Bind[Signing](c)
if fields := hyperchi.FieldErrors(err); fields != nil {
return c.Status(422).Fragment("guestbook-form", hyperchi.H{"in": in, "errors": fields})
}
if err != nil {
return err
}
entry := Entry{Name: in.Name, Message: in.Message, At: time.Now()}
book.Add(entry)
if err := app.Room("guestbook").Swap("#entries", "beforeend", "entry", entry); err != nil {
return err
}
return c.Fragment("guestbook-form", hyperchi.H{})
})
app.WebSocket("/live", func(s *hyperchi.Socket) error {
s.Join("guestbook")
return s.Wait()
})
if err := app.Serve("0.0.0.0:8080"); err != nil {
log.Fatal(err)
}
}
Run it and open http://localhost:8080 in two browser windows:
go mod tidy
go run .
What happens on a request
Reading the page. GET / returns c.View("guestbook", ...). A browser navigation gets the page inside layout-base; an htmx request for the same URL would get the page template alone. You never branch on the request type yourself.
Signing with a mistake. Submit the form with an empty name. Bind[Signing] decodes the form fields named by the form tags and checks the validate rules. It returns an error that carries per-field messages, and FieldErrors pulls them out. The handler answers 422 with the form fragment, the values the visitor typed, and the messages. htmx 4 swaps every status except 204 and 304, so the form with its errors replaces the old one and the message the visitor wrote is still there.
If you return the error from Bind directly instead, HyperChi still answers 422 with the field messages, as JSON for an API client or a small alert for htmx. Re-rendering your own fragment is how you put the messages next to the fields. The forms guide covers the validation rules, custom Validate methods, CSRF, and uploads.
Signing successfully. The entry is stored, then app.Room("guestbook").Swap(...) renders the entry fragment once and sends it to every socket in the room. The htmx WebSocket extension in each browser appends it to #entries. The poster gets a fresh, empty form as the HTTP response.
The socket. app.WebSocket("/live", ...) accepts the connection with the app's origin policy, then runs your handler. s.Join("guestbook") puts the socket in the room and s.Wait() holds it open until the browser leaves. The hub owns writes and pings, so the handler has nothing else to do. A socket that only listens never needs to read; when you want browsers to send messages, see Realtime.
Entries live in memory, so they reset when the process restarts. For storage that survives, use the SQLite layer from Data and KV, or your own store behind the same
AddandAllmethods.
Before you put it online
This app is complete, but a public guestbook needs a few more things, each covered in its own guide:
- CSRF protection. Call
app.AutoMiddleware()before the routes. It turns on CSRF protection, so puthx-headers:inheritedwith.csrfTokenon thebody, as the forms guide shows. - A production configuration. Set
ENVIRONMENT=production, which tightens WebSocket origins and error detail. See Configuration and Security. - One binary. Embed the templates with
LoadTemplatesFSand build withCGO_ENABLED=0, as Deploying describes. - Tests. Drive the handlers through
httptest, including the 422 path. See Testing.
Next, the routing guide covers every way a handler can answer.