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:

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:

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 Add and All methods.

Before you put it online

This app is complete, but a public guestbook needs a few more things, each covered in its own guide:

Next, the routing guide covers every way a handler can answer.