This tutorial will guide you through understanding and building features in Northstar, a modern web application stack combining Go, NATS, Datastar, and Templ. It's designed for developers new to Go or the hypermedia/reactive patterns that Datastar enables.
- Understanding the Stack
- Core Concepts
- Project Architecture
- Building Your First Feature
- Working with State and Persistence
- Advanced Patterns
- Development Workflow
Northstar is a hypermedia-driven web application framework that lets you build reactive, real-time web applications without writing JavaScript. It's a modern alternative to traditional Single Page Applications (SPAs), combining:
- Server-side rendering for initial page loads
- Server-Sent Events (SSE) for real-time updates
- Partial HTML updates instead of JSON APIs
- Minimal client-side JavaScript (only Datastar's runtime)
Go is a statically-typed, compiled language known for:
- Simplicity: Easy to learn, readable syntax
- Concurrency: Built-in goroutines for handling many connections
- Performance: Fast compilation and execution
- Standard library: Rich built-in HTTP server, templating, etc.
Why Go for web apps? Go's excellent HTTP support, concurrency model, and single binary deployment make it ideal for web servers.
NATS is a lightweight messaging system. Northstar uses it for:
- Key-Value Storage: Storing user session data (like todos)
- Pub/Sub: Real-time notifications between server and clients
- JetStream: Persistence layer for data
Why embedded NATS? Instead of managing a separate database, NATS runs inside your Go application, simplifying deployment and providing built-in real-time capabilities.
Datastar is a JavaScript library (~14KB) that:
- Binds HTML to reactive state using data attributes
- Handles Server-Sent Events for live updates
- Updates DOM automatically when server sends new HTML
- Provides form handling without writing JavaScript
Think of it as: HTMX + Alpine.js combined, but with one unified API.
Key Datastar attributes:
data-on-load: Run action when element loadsdata-on-click: Run action on clickdata-signals: Define reactive statedata-text: Bind element text to a signal (reactive variable)data-bind-input: Two-way bind input to state
Templ generates Go code from template files:
- Type-safe: Templates are checked at compile time
- Go-native: Write Go code inside templates
- Fast: Compiled to Go functions, no runtime parsing
- Composable: Templates call other templates
Example:
templ Hello(name string) {
<div>Hello, {name}!</div>
}Compiles to Go code you call like: Hello("World").Render(ctx, w)
Chi is a lightweight HTTP router:
- Middleware support: Logging, recovery, etc.
- RESTful routing: Clean URL patterns
- Sub-routing: Group related routes
Traditional SPAs:
Client (React/Vue) <--JSON--> Server (REST API)
↓
Maintains state
Renders HTML
Northstar approach:
Client (Datastar) <--HTML fragments--> Server (Go + Templ)
↓ ↓
Minimal state Maintains state
Auto-updates DOM Renders HTML
Benefits:
- Simpler mental model: Server controls everything
- No API layer: Server sends HTML directly
- Less JavaScript: Just Datastar runtime
- Better performance: Server-side rendering + targeted updates
SSE provides one-way real-time communication from server to client:
Client Server
| |
|-- HTTP GET /counter/data -->|
| |
|<-- SSE Stream Open ---------|
|<-- HTML fragment 1 ---------|
|<-- HTML fragment 2 ---------|
|<-- Signal update ----------|
|<-- Connection close --------|
Why SSE over WebSockets?
- Simpler protocol (just HTTP)
- Works through proxies/firewalls
- Auto-reconnection built-in
- Sufficient for server → client updates
Signals are Datastar's reactive variables:
<div data-signals="{count: 0}">
<div data-text="$count">0</div>
<button data-on-click="$count++">Increment</button>
</div>The $count signal:
- Is reactive: When it changes,
data-textupdates automatically - Can be updated from JavaScript expressions (
$count++) - Can be updated from server (via SSE signals update)
Server can send signal updates:
// Server sends: data: signal {"count": 5}
// Client automatically updates all elements bound to $countNorthstar organizes code by feature (vertical slicing), not layer (horizontal):
features/counter/
├── handlers.go # HTTP request handlers
├── routes.go # URL routing setup
└── pages/
└── counter.templ # UI templates
Why vertical?
- All related code in one place
- Easy to add/remove features
- Clear feature boundaries
- Teams can work independently
northstar/
├── cmd/
│ └── web/
│ └── main.go # Application entry point
├── config/
│ └── config.go # Configuration management
├── features/ # Feature modules
│ ├── common/ # Shared components
│ │ ├── components/ # Reusable UI components
│ │ └── layouts/ # Page layouts
│ ├── counter/ # Counter feature
│ │ ├── handlers.go # HTTP handlers
│ │ ├── routes.go # Route setup
│ │ └── pages/
│ │ └── counter.templ # UI templates
│ └── index/ # Todo feature
│ ├── handlers.go
│ ├── routes.go
│ ├── components/
│ │ └── todo.templ
│ ├── pages/
│ │ └── index.templ
│ └── services/
│ └── todo_service.go # Business logic
├── nats/
│ └── nats.go # NATS server setup
├── router/
│ └── router.go # Main router setup
└── web/
└── resources/ # Static assets (CSS, JS)
Let's trace a request through the system:
-
User clicks "Increment Global" button
<button data-on-click="@post('/counter/increment/global')">
-
Datastar sends POST request to
/counter/increment/global -
Chi router matches route → calls handler
router.Post("/counter/increment/global", handlers.IncrementGlobal)
-
Handler processes request
func (h *Handlers) IncrementGlobal(w http.ResponseWriter, r *http.Request) { // Update state newCount := h.globalCounter.Add(1) // Send SSE response with signal update datastar.NewSSE(w, r).MarshalAndPatchSignals(update) }
-
Server sends SSE response with signal update
data: signal {"global": 42} -
Datastar updates DOM - all elements with
data-text="$global"update automatically
cmd/web/main.go:
func main() {
// 1. Setup context for graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// 2. Setup logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: config.Global.LogLevel,
}))
// 3. Run server
if err := run(ctx); err != nil {
slog.Error("error running server", "error", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
// 4. Create HTTP router
r := chi.NewMux()
r.Use(middleware.Logger, middleware.Recoverer)
// 5. Setup sessions
sessionStore := sessions.NewCookieStore([]byte(config.Global.SessionSecret))
// 6. Start embedded NATS server
ns, err := nats.SetupNATS(ctx)
// 7. Setup all feature routes
router.SetupRoutes(ctx, r, sessionStore, ns)
// 8. Start HTTP server
srv := &http.Server{Addr: addr, Handler: r}
srv.ListenAndServe()
}Key concepts:
- Context: Used for cancellation and graceful shutdown
- Middleware: Functions that wrap handlers (logging, recovery)
- Sessions: Cookie-based session management
- Embedded NATS: Runs in the same process as HTTP server
Before building your own feature, let's deeply understand the Todo feature - the most complete example in Northstar. We'll trace through every part of the system to see how it all works together.
The Todo app (features/index/) is a full-featured TodoMVC implementation that demonstrates:
- CRUD operations (Create, Read, Update, Delete)
- Real-time sync across browser tabs using NATS
- Persistent storage with NATS Key-Value
- Complex UI interactions (inline editing, filtering)
- Session management (each user has their own todos)
- Optimized updates (only changed HTML fragments sent)
User Browser Go Server NATS
| | |
|-- GET / ------------------>| |
|<-- HTML Page --------------| |
| | |
|-- GET /api/todos (SSE) --->| |
| |-- Get session data ----->|
| |<-- Todo data ------------|
|<-- SSE: HTML fragment -----| |
| | |
|-- POST /api/todos/0/toggle>| |
| |-- Update todos --------->|
| | |-- NATS notifies
|<-- SSE: New HTML --------- |<-- Watch notification ---|
Key insight: A single GET request to /api/todos establishes a persistent SSE connection that stays open. Any changes to todos (from this tab or other tabs) automatically push updates through this connection.
features/index/
├── handlers.go # HTTP request handlers (the controllers)
├── routes.go # URL routing configuration
├── components/
│ └── todo.templ # Todo UI components (reusable pieces)
├── pages/
│ └── index.templ # Main page template
└── services/
└── todo_service.go # Business logic & NATS interaction
Why this structure?
- Separation of concerns: UI (templ) separate from logic (service) separate from HTTP (handlers)
- Testability: Can test service logic without HTTP
- Reusability: Components can be used in different pages
What happens when you visit http://localhost:8080/
features/index/routes.go:19:
router.Get("/", handlers.IndexPage)Chi router sees GET / and calls IndexPage handler.
features/index/handlers.go:26-29:
func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) {
if err := pages.IndexPage("Northstar").Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}What this does:
- Calls the
IndexPagetemplate - Renders it to the
http.ResponseWriter - Sends full HTML page to browser
Key concept: This is a traditional page render. No JSON, no client-side rendering - just HTML.
features/index/pages/index.templ:9-16:
templ IndexPage(title string) {
@layouts.Base(title) {
<div class="flex flex-col w-full min-h-screen">
@components.Navigation(components.PageIndex)
<div id="todos-container"
data-on-load={ datastar.GetSSE("/api/todos") }>
</div>
</div>
}
}Breaking this down:
@layouts.Base(title): Wraps content in base layout (HTML structure, CSS, Datastar script)@components.Navigation: Renders navigation bar<div id="todos-container">: Empty container for todosdata-on-load={ datastar.GetSSE("/api/todos") }: The magic!
What data-on-load does:
- When this element loads in the browser...
- Datastar automatically makes a GET request to
/api/todos - Request has
Accept: text/event-streamheader (SSE) - Connection stays open, waiting for updates
Browser output at this point:
<!DOCTYPE html>
<html>
<head>
<script src="/static/datastar/datastar.js"></script>
<link href="/static/index.css" rel="stylesheet">
</head>
<body>
<nav>...</nav>
<div id="todos-container" data-on-load="@get('/api/todos')"></div>
</body>
</html>Browser now has HTML page, Datastar loads, sees data-on-load, makes SSE request.
Browser makes request: GET /api/todos (with SSE headers)
features/index/routes.go:23:
apiRouter.Route("/todos", func(todosRouter chi.Router) {
todosRouter.Get("/", handlers.TodosSSE)
// ... other routes
})Routes to TodosSSE handler.
features/index/handlers.go:32-71:
func (h *Handlers) TodosSSE(w http.ResponseWriter, r *http.Request) {
// 1. Get or create user's todo list
sessionID, mvc, err := h.todoService.GetSessionMVC(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 2. Create SSE writer
sse := datastar.NewSSE(w, r)
// 3. Setup NATS watcher for real-time updates
ctx := r.Context()
watcher, err := h.todoService.WatchUpdates(ctx, sessionID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer watcher.Stop()
// 4. Listen for updates forever
for {
select {
case <-ctx.Done():
// Client disconnected
return
case entry := <-watcher.Updates():
// NATS notified us of a change!
if entry == nil {
continue
}
// Deserialize updated data
if err := json.Unmarshal(entry.Value(), mvc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Render and send updated HTML
c := components.TodosMVCView(mvc)
if err := sse.PatchElementTempl(c); err != nil {
// Handle error...
return
}
}
}
}This is the heart of the real-time system! Let's break it down:
h.todoService.GetSessionMVC(w, r) calls into the service:
features/index/services/todo_service.go:53-76:
func (s *TodoService) GetSessionMVC(w http.ResponseWriter, r *http.Request)
(string, *components.TodoMVC, error) {
ctx := r.Context()
// Get or create session ID (stored in cookie)
sessionID, err := s.upsertSessionID(r, w)
if err != nil {
return "", nil, fmt.Errorf("failed to get session id: %w", err)
}
mvc := &components.TodoMVC{}
// Try to get existing todos from NATS
if entry, err := s.kv.Get(ctx, sessionID); err != nil {
if err != jetstream.ErrKeyNotFound {
return "", nil, fmt.Errorf("failed to get key value: %w", err)
}
// First time user - create default todos
s.resetMVC(mvc)
if err := s.saveMVC(ctx, sessionID, mvc); err != nil {
return "", nil, fmt.Errorf("failed to save mvc: %w", err)
}
} else {
// Existing user - deserialize their todos
if err := json.Unmarshal(entry.Value(), mvc); err != nil {
return "", nil, fmt.Errorf("failed to unmarshal mvc: %w", err)
}
}
return sessionID, mvc, nil
}What's happening:
- Get session ID: Uses gorilla/sessions to get/set a cookie with unique user ID
- Query NATS: Try to get todos for this session ID from NATS KV store
- First-time user?: Create default todos, save to NATS
- Returning user?: Deserialize their saved todos
- Return: Session ID + todo data
NATS Key structure: sessionID (e.g., "user_abc123") → JSON blob of todos
watcher, err := h.todoService.WatchUpdates(ctx, sessionID)This calls:
features/index/services/todo_service.go:86-88:
func (s *TodoService) WatchUpdates(ctx context.Context, sessionID string)
(jetstream.KeyWatcher, error) {
return s.kv.Watch(ctx, sessionID)
}What Watch does:
- Returns a channel that receives notifications
- Whenever the key
sessionIDchanges in NATS... - A message appears on
watcher.Updates()channel - This enables real-time sync across browser tabs!
for {
select {
case <-ctx.Done():
return // Client disconnected
case entry := <-watcher.Updates():
// Got an update from NATS!
// Deserialize, render, send via SSE
c := components.TodosMVCView(mvc)
sse.PatchElementTempl(c)
}
}This loop runs forever (until client disconnects):
- Waits for either:
ctx.Done(): Client closed connection → exitwatcher.Updates(): NATS says data changed → send update
- When data changes:
- Deserialize from NATS
- Render TodosMVCView component
- Send HTML fragment via SSE
- Repeat
SSE Protocol:
Server sends:
data: <div id="todos-container">...full HTML...</div>
Browser receives this, Datastar:
- Parses the HTML
- Finds element with
id="todos-container" - Replaces its contents with new HTML
When watcher first starts, NATS sends the current value. So immediately:
watcher.Updates()receives current todo data- Loop processes it
- Sends initial HTML to browser
- Browser updates
#todos-container
User sees: Full todo list appears instantly!
User clicks checkbox to complete a todo
features/index/components/todo.templ:186-198:
<li class="flex items-center gap-8 p-2 group" id={ fmt.Sprintf("todo%d", i) }>
<label
id={ fmt.Sprintf("toggle%d", i) }
class="text-4xl cursor-pointer"
data-on-click={ datastar.PostSSE("/api/todos/%d/toggle", i) }
data-indicator={ fetchingSignalName }
>
if todo.Completed {
@common.Icon("material-symbols:check-box-outline")
} else {
@common.Icon("material-symbols:check-box-outline-blank")
}
</label>
<!-- ... more UI ... -->
</li>Key attribute: data-on-click={ datastar.PostSSE("/api/todos/%d/toggle", i) }
For todo at index 0, this becomes:
<label data-on-click="@post('/api/todos/0/toggle')">When clicked:
- Datastar intercepts the click
- Makes POST request to
/api/todos/0/toggle - Automatically sets
$fetching0 = true(fromdata-indicator)
features/index/routes.go:28-29:
todoRouter.Route("/{idx}", func(todoRouter chi.Router) {
todoRouter.Post("/toggle", handlers.ToggleTodo)
// ...
})Routes POST /api/todos/0/toggle → ToggleTodo handler
URL parameter: {idx} captures 0 from URL
features/index/handlers.go:133-155:
func (h *Handlers) ToggleTodo(w http.ResponseWriter, r *http.Request) {
// 1. Get current todo state
sessionID, mvc, err := h.todoService.GetSessionMVC(w, r)
sse := datastar.NewSSE(w, r)
if err != nil {
if err := sse.ConsoleError(err); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
return
}
// 2. Parse index from URL
i, err := h.parseIndex(w, r)
if err != nil {
if err := sse.ConsoleError(err); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
return
}
// 3. Toggle the todo
h.todoService.ToggleTodo(mvc, i)
// 4. Save back to NATS
if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}Flow:
- Get current state: Fetch todos from NATS (via session)
- Parse index: Extract
0from URL (/api/todos/0/toggle) - Toggle business logic: Call service method
- Save to NATS: Persist changes
Important: Handler doesn't send HTML response! It just saves to NATS and returns.
features/index/services/todo_service.go:90-106:
func (s *TodoService) ToggleTodo(mvc *components.TodoMVC, index int) {
if index < 0 {
// Special case: toggle ALL todos
setCompletedTo := false
for _, todo := range mvc.Todos {
if !todo.Completed {
setCompletedTo = true
break
}
}
for _, todo := range mvc.Todos {
todo.Completed = setCompletedTo
}
} else if index < len(mvc.Todos) {
// Toggle single todo
todo := mvc.Todos[index]
todo.Completed = !todo.Completed
}
}Simple business logic:
- If
index = -1: Toggle all todos to same state - Otherwise: Flip the
Completedboolean
MVC modification: Changes happen to the mvc pointer in memory.
Back in handler:
h.todoService.SaveMVC(r.Context(), sessionID, mvc)features/index/services/todo_service.go:142-151:
func (s *TodoService) saveMVC(ctx context.Context, sessionID string, mvc *components.TodoMVC) error {
// Serialize to JSON
b, err := json.Marshal(mvc)
if err != nil {
return fmt.Errorf("failed to marshal mvc: %w", err)
}
// Save to NATS KV
if _, err := s.kv.Put(ctx, sessionID, b); err != nil {
return fmt.Errorf("failed to put key value: %w", err)
}
return nil
}What happens:
- Marshal todos to JSON:
{"todos":[...], "editingIdx":-1, "mode":0} - Put into NATS:
kv.Put(ctx, "user_abc123", jsonBytes) - NATS broadcasts change notification!
Remember the SSE connection from Step 2? It's still open, running this loop:
for {
case entry := <-watcher.Updates():
// This fires NOW because NATS detected the change!
json.Unmarshal(entry.Value(), mvc)
c := components.TodosMVCView(mvc)
sse.PatchElementTempl(c)
}Sequence:
kv.Put()saves to NATS- NATS detects key changed
- NATS sends notification to all watchers (including our SSE connection)
- Watcher receives update in
Updates()channel - Loop renders new HTML
- HTML sent via SSE to browser
- Datastar receives SSE event
- Datastar updates
#todos-containerDOM
Result: Todo checkbox updates immediately!
Bonus: If you have multiple browser tabs open, they ALL have watchers, so they ALL receive the update!
User clicks todo text to edit it
features/index/components/todo.templ:199-206:
<label
id={ indicatorID }
class="flex-1 text-lg cursor-pointer select-none"
data-on-click={ datastar.GetSSE("/api/todos/%d/edit", i) }
data-indicator={ fetchingSignalName }
>
{ todo.Text }
</label>Clicks label → GET /api/todos/0/edit
features/index/handlers.go:157-173:
func (h *Handlers) StartEdit(w http.ResponseWriter, r *http.Request) {
sessionID, mvc, err := h.todoService.GetSessionMVC(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
i, err := h.parseIndex(w, r)
if err != nil {
return
}
// Mark this todo as being edited
h.todoService.StartEditing(mvc, i)
// Save to NATS
if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}features/index/services/todo_service.go:134-136:
func (s *TodoService) StartEditing(mvc *components.TodoMVC, index int) {
mvc.EditingIdx = index
}Just sets editingIdx = 0 and saves. NATS watcher triggers, sends updated HTML.
features/index/components/todo.templ:175-221:
templ TodoRow(mode TodoViewMode, todo *Todo, i int, isEditing bool) {
if isEditing {
@TodoInput(i) // Show input field!
} else if (mode == TodoViewModeAll) || ... {
// Show normal todo row
}
}When isEditing = true:
features/index/components/todo.templ:157-173:
templ TodoInput(i int) {
<input
id="todoInput"
class="flex-1 w-full italic input input-bordered input-lg"
placeholder="What needs to be done?"
data-bind-input // Binds to $input signal
data-on-keydown={ fmt.Sprintf(`
if (evt.key !== 'Enter' || !$input.trim().length) return;
%s;
$input = '';
`, datastar.PutSSE("/api/todos/%d/edit",i) ) }
if i >= 0 {
data-on-click__outside={ datastar.PutSSE("/api/todos/cancel") }
}
/>
}What this creates:
- Input field replaces label
data-bind-input: Two-way binds input value to$inputsignaldata-on-keydown: On Enter key:- Check if input is not empty
- Send
PUT /api/todos/0/editwith current$inputvalue - Clear input (
$input = '')
data-on-click__outside: If user clicks outside, cancel edit
Browser sees:
<input
data-bind-input
data-on-keydown="if (evt.key !== 'Enter' ...) @put('/api/todos/0/edit'); $input = '';"
data-on-click__outside="@put('/api/todos/cancel')"
/>Datastar automatically:
- Binds input value to
$inputsignal - On Enter key pressed
- Makes
PUT /api/todos/0/edit - Automatically includes current signals as form data!
Request payload:
POST /api/todos/0/edit
Content-Type: application/x-www-form-urlencoded
input=Buy+groceries
features/index/handlers.go:175-205:
func (h *Handlers) SaveEdit(w http.ResponseWriter, r *http.Request) {
// 1. Define struct matching signal names
type Store struct {
Input string `json:"input"`
}
store := &Store{}
// 2. Datastar helper extracts signals from request
if err := datastar.ReadSignals(r, store); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if store.Input == "" {
return
}
// 3. Get current state
sessionID, mvc, err := h.todoService.GetSessionMVC(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// 4. Parse index
i, err := h.parseIndex(w, r)
if err != nil {
return
}
// 5. Update todo text
h.todoService.EditTodo(mvc, i, store.Input)
// 6. Save to NATS
if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}Key: datastar.ReadSignals(r, store) deserializes form data into Go struct.
features/index/services/todo_service.go:108-118:
func (s *TodoService) EditTodo(mvc *components.TodoMVC, index int, text string) {
if index >= 0 && index < len(mvc.Todos) {
// Edit existing todo
mvc.Todos[index].Text = text
} else if index < 0 {
// Add new todo
mvc.Todos = append(mvc.Todos, &components.Todo{
Text: text,
Completed: false,
})
}
mvc.EditingIdx = -1 // Stop editing
}Logic:
- If
index >= 0: Update existing todo's text - If
index = -1: Append new todo - Set
editingIdx = -1(exit edit mode)
Save to NATS → Watcher notifies → SSE sends update → Input replaced with label showing new text
User clicks "Active" to show only incomplete todos
features/index/components/todo.templ:113-125:
<div class="join">
for i := TodoViewModeAll; i < TodoViewModeLast; i++ {
if i == mvc.Mode {
<div class="btn btn-xs btn-primary join-item">
{ TodoViewModeStrings[i] }
</div>
} else {
<button
class="btn btn-xs join-item"
data-on-click={ datastar.PutSSE("/api/todos/mode/%d", i) }
>
{ TodoViewModeStrings[i] }
</button>
}
}
</div>Generates:
<div class="join">
<div class="btn btn-primary">All</div>
<button data-on-click="@put('/api/todos/mode/1')">Active</button>
<button data-on-click="@put('/api/todos/mode/2')">Completed</button>
</div>Click "Active" → PUT /api/todos/mode/1
features/index/handlers.go:106-131:
func (h *Handlers) SetMode(w http.ResponseWriter, r *http.Request) {
sessionID, mvc, err := h.todoService.GetSessionMVC(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Extract mode from URL: /api/todos/mode/1
modeStr := chi.URLParam(r, "mode")
modeRaw, err := strconv.Atoi(modeStr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mode := components.TodoViewMode(modeRaw)
// Validate mode
if mode < components.TodoViewModeAll || mode > components.TodoViewModeCompleted {
http.Error(w, "invalid mode", http.StatusBadRequest)
return
}
// Update mode
h.todoService.SetMode(mvc, mode)
// Save to NATS
if err := h.todoService.SaveMVC(r.Context(), sessionID, mvc); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}features/index/services/todo_service.go:130-132:
func (s *TodoService) SetMode(mvc *components.TodoMVC, mode components.TodoViewMode) {
mvc.Mode = mode
}Just updates mvc.Mode = 1 (Active), saves to NATS.
features/index/components/todo.templ:175-221:
templ TodoRow(mode TodoViewMode, todo *Todo, i int, isEditing bool) {
if isEditing {
@TodoInput(i)
} else if (
mode == TodoViewModeAll) ||
(mode == TodoViewModeActive && !todo.Completed) ||
(mode == TodoViewModeCompleted && todo.Completed) {
<!-- Show todo row -->
}
<!-- If conditions not met, render nothing! -->
}Filtering logic in template:
TodoViewModeAll(0): Show all todosTodoViewModeActive(1): Show only if!todo.CompletedTodoViewModeCompleted(2): Show only iftodo.Completed
Result: Only matching todos rendered in HTML. Server-side filtering!
Traditional SPA:
Client (React) Server (REST API)
|-- GET /api/todos ---------->|
|<-- JSON: [{...}] ------------|
| |
| [Client renders HTML] |
| [User clicks checkbox] |
| |
|-- PATCH /api/todos/1 ------->|
|<-- JSON: {...} --------------|
| |
| [Client re-renders] |
Northstar approach:
Client (Datastar) Server (Handlers + Templ)
|-- GET /api/todos (SSE) ---->|
|<-- HTML: <li>...</li> -------|
| |
| [Datastar updates DOM] |
| [User clicks checkbox] |
| |
|-- POST /api/todos/1/toggle ->|
| | [Saves to NATS]
|<-- HTML: <li>...</li> -------| [Watcher notifies]
| |
| [Datastar updates DOM] |
Benefits:
- No client-side state management
- No JSON serialization/deserialization
- No client-side rendering logic
- Just ~14KB Datastar script vs ~100KB+ React bundle
The pattern:
// Open SSE connection
watcher := nats.Watch(key)
// Loop forever
for update := range watcher.Updates() {
// Render new HTML
html := renderTemplate(update)
// Send via SSE
sse.Send(html)
}Why this works:
- NATS acts as message bus
- Every client has a watcher
- Any update broadcasts to all watchers
- Each watcher sends HTML to its client
Real-world example: Two browser tabs
Tab 1 SSE ←─────┐
├── NATS KV Store
Tab 2 SSE ←─────┘
Tab 1: User clicks checkbox
→ POST handler updates NATS
→ NATS notifies ALL watchers
→ Tab 1 SSE sends HTML
→ Tab 2 SSE sends HTML
→ Both tabs update instantly!
The app works even without JavaScript:
- Disable JavaScript in browser
- Visit page → Full HTML loads
- Click buttons → Traditional form POST → Page reloads → Works!
With JavaScript:
- Datastar intercepts clicks
- Makes AJAX requests
- Updates DOM without reload
Graceful degradation: Core functionality works, enhanced experience with JS.
Filtering example:
- Client doesn't decide which todos to show
- Server renders only visible todos
- Client just displays what server sends
Benefits:
- Security: Business logic on server (can't be bypassed)
- Consistency: One source of truth
- Performance: Server more powerful than phones
- SEO: HTML content immediately available
Templ templates are compiled Go code:
templ TodoRow(todo *Todo, i int) {
<li>{todo.Text}</li>
}Becomes:
func TodoRow(todo *Todo, i int) templ.Component {
// ... generated code
}Compiler checks:
todomust be*Todotypeimust beint- Can't pass wrong types
Compare to traditional templates:
<!-- No type checking! -->
<li>{{.todo.Texts}}</li> <!-- Typo: Texts vs Text -->Runtime error vs compile-time error!
Now let's build the Todo feature from the ground up. We'll progress through these stages:
- Basic routing and static pages (traditional Go web)
- Adding interactivity with Datastar (the hypermedia layer)
- Adding state persistence with NATS (the database layer)
- Adding real-time sync with SSE (the real-time layer)
This progression helps you understand each layer independently before combining them.
You should be comfortable with:
- Go basics (structs, interfaces, functions)
- HTTP concepts (GET, POST, request/response)
- Basic HTML
We'll explain NATS and Datastar concepts as we go.
Goal: Render a static todo list using Go handlers and Templ templates.
mkdir -p features/todos/pages
mkdir -p features/todos/componentsfeatures/todos/components/models.go:
package components
// ViewMode determines which todos to display
type ViewMode int
const (
ViewModeAll ViewMode = iota
ViewModeActive
ViewModeCompleted
)
// Todo represents a single todo item
type Todo struct {
Text string `json:"text"`
Completed bool `json:"completed"`
}
// TodoMVC holds the complete state of the todo app
type TodoMVC struct {
Todos []*Todo `json:"todos"` // List of todos
EditingIdx int `json:"editingIdx"` // Index being edited (-1 = none)
Mode ViewMode `json:"mode"` // Current filter mode
}Why this structure?
Todo: Single responsibility - one todo itemTodoMVC: Aggregate root - complete app stateViewMode: Type-safe enum instead of strings
features/todos/pages/index.templ:
package pages
import "northstar/features/common/layouts"
templ TodoPage(title string) {
@layouts.Base(title) {
<div class="flex flex-col w-full min-h-screen">
<h1 class="text-4xl font-bold text-center m-4">Todos</h1>
<div id="todos-container">
<p>Loading todos...</p>
</div>
</div>
}
}features/todos/components/todo_list.templ:
package components
import "fmt"
templ TodoList(mvc *TodoMVC) {
<div id="todos-container" class="max-w-2xl mx-auto p-4">
<h2 class="text-2xl font-bold mb-4">Todo List</h2>
if len(mvc.Todos) == 0 {
<p class="text-gray-500">No todos yet!</p>
} else {
<ul class="space-y-2">
for i, todo := range mvc.Todos {
<li class="flex items-center gap-4 p-2 border rounded">
<span class="flex-1">{ todo.Text }</span>
if todo.Completed {
<span class="text-green-500">✓</span>
}
</li>
}
</ul>
}
<div class="mt-4 text-sm text-gray-600">
Total: { fmt.Sprint(len(mvc.Todos)) } todos
</div>
</div>
}Template concepts:
templ TodoList(mvc *TodoMVC): Function-like signature with typed parametersfor i, todo := range mvc.Todos: Regular Go loopsif len(mvc.Todos) == 0: Regular Go conditionals{ todo.Text }: Expression interpolation
go tool templ generateThis creates *_templ.go files with compiled template functions.
features/todos/handlers.go:
package todos
import (
"net/http"
"northstar/features/todos/components"
"northstar/features/todos/pages"
)
type Handlers struct {
// Will add dependencies later
}
func NewHandlers() *Handlers {
return &Handlers{}
}
// TodoPage renders the main page
func (h *Handlers) TodoPage(w http.ResponseWriter, r *http.Request) {
if err := pages.TodoPage("Todos").Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page",
http.StatusInternalServerError)
}
}
// TodosData sends the initial todo list
func (h *Handlers) TodosData(w http.ResponseWriter, r *http.Request) {
// Hardcoded data for now
mvc := &components.TodoMVC{
Todos: []*components.Todo{
{Text: "Learn Go", Completed: true},
{Text: "Learn NATS", Completed: false},
{Text: "Learn Datastar", Completed: false},
},
EditingIdx: -1,
Mode: components.ViewModeAll,
}
// Render component directly to response
if err := components.TodoList(mvc).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}Handler pattern:
- Accept
http.ResponseWriterand*http.Request - Create/fetch data
- Render template
- Handle errors
features/todos/routes.go:
package todos
import "github.com/go-chi/chi/v5"
func SetupRoutes(router chi.Router) error {
handlers := NewHandlers()
// Main page route
router.Get("/todos", handlers.TodoPage)
// Data route
router.Get("/todos/data", handlers.TodosData)
return nil
}Edit router/router.go:
import todosFeature "northstar/features/todos"
func SetupRoutes(ctx context.Context, router chi.Router, ...) error {
// ... existing code ...
if err := errors.Join(
todosFeature.SetupRoutes(router), // Add this
// ... other features ...
); err != nil {
return fmt.Errorf("error setting up routes: %w", err)
}
return nil
}go tool task liveVisit http://localhost:8080/todos/data - you should see static HTML with 3 todos.
What we've built:
- ✅ Go routing with Chi
- ✅ Type-safe templates with Templ
- ✅ Basic data structures
- ❌ No interactivity yet
- ❌ No persistence yet
Goal: Make the page interactive - users can toggle, add, delete todos.
Key concept: Instead of rendering HTML on first page load only, we'll send HTML fragments on every action. This is the CQRS pattern:
- Commands: User actions (POST/PUT/DELETE requests)
- Queries: Data fetches (GET requests returning HTML)
features/todos/pages/index.templ:
package pages
import (
"northstar/features/common/layouts"
"github.com/starfederation/datastar-go/datastar"
)
templ TodoPage(title string) {
@layouts.Base(title) {
<div class="flex flex-col w-full min-h-screen">
<h1 class="text-4xl font-bold text-center m-4">Todos</h1>
// Datastar: Load data when element appears
<div id="todos-container"
data-on-load={ datastar.GetSSE("/todos/data") }>
<p>Loading todos...</p>
</div>
</div>
}
}What data-on-load does:
- When this div renders in browser
- Datastar automatically makes GET request to
/todos/data - Response HTML replaces contents of
#todos-container
features/todos/components/todo_list.templ:
package components
import (
"fmt"
"github.com/starfederation/datastar-go/datastar"
)
templ TodoList(mvc *TodoMVC) {
<div id="todos-container" class="max-w-2xl mx-auto p-4">
<h2 class="text-2xl font-bold mb-4">Todo List</h2>
// Add new todo form
<div class="mb-4">
<input
id="new-todo"
type="text"
placeholder="What needs to be done?"
class="w-full p-2 border rounded"
data-bind-input
data-on-keydown={ `
if (evt.key === 'Enter' && $input.trim()) {
` + datastar.PostSSE("/todos/add") + `;
$input = '';
}
` }
/>
</div>
if len(mvc.Todos) == 0 {
<p class="text-gray-500">No todos yet!</p>
} else {
<ul class="space-y-2">
for i, todo := range mvc.Todos {
@TodoRow(todo, i)
}
</ul>
}
<div class="mt-4 flex justify-between items-center">
<span class="text-sm text-gray-600">
{ fmt.Sprint(len(mvc.Todos)) } todos
</span>
</div>
</div>
}
templ TodoRow(todo *Todo, index int) {
<li class="flex items-center gap-4 p-2 border rounded group">
// Toggle checkbox
<input
type="checkbox"
checked?={ todo.Completed }
class="w-5 h-5"
data-on-click={ datastar.PostSSE("/todos/%d/toggle", index) }
/>
// Todo text
<span class={ "flex-1", templ.KV("line-through text-gray-400", todo.Completed) }>
{ todo.Text }
</span>
// Delete button (visible on hover)
<button
class="text-red-500 opacity-0 group-hover:opacity-100"
data-on-click={ datastar.DeleteSSE("/todos/%d", index) }
>
✕
</button>
</li>
}Datastar attributes explained:
| Attribute | Purpose | Example |
|---|---|---|
data-bind-input |
Two-way bind input to $input signal |
User types → $input updates |
data-on-keydown |
Run action on key press | Enter key → POST request |
data-on-click |
Run action on click | Click checkbox → POST /toggle |
@post() / @delete() |
Datastar helpers for HTTP methods | datastar.PostSSE("/path") |
The CQRS pattern here:
- Command:
data-on-click={ datastar.PostSSE("/todos/0/toggle") }- Sends command to server: "toggle todo 0"
- Server processes command
- Server sends back updated HTML
- Query: Response is HTML fragment
- Not JSON!
- Ready-to-display HTML
- Datastar swaps it into DOM
features/todos/handlers.go:
package todos
import (
"fmt"
"net/http"
"strconv"
"northstar/features/todos/components"
"northstar/features/todos/pages"
"github.com/go-chi/chi/v5"
"github.com/starfederation/datastar-go/datastar"
)
type Handlers struct {
// In-memory storage for now
todos *components.TodoMVC
}
func NewHandlers() *Handlers {
return &Handlers{
todos: &components.TodoMVC{
Todos: []*components.Todo{
{Text: "Learn Go", Completed: true},
{Text: "Learn NATS", Completed: false},
{Text: "Learn Datastar", Completed: false},
},
EditingIdx: -1,
Mode: components.ViewModeAll,
},
}
}
func (h *Handlers) TodoPage(w http.ResponseWriter, r *http.Request) {
if err := pages.TodoPage("Todos").Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page",
http.StatusInternalServerError)
}
}
func (h *Handlers) TodosData(w http.ResponseWriter, r *http.Request) {
// Create SSE writer
sse := datastar.NewSSE(w, r)
// Send todo list as HTML fragment
if err := sse.PatchElementTempl(components.TodoList(h.todos)); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}
// AddTodo handles adding a new todo
func (h *Handlers) AddTodo(w http.ResponseWriter, r *http.Request) {
// Parse form data to get the $input signal
type Input struct {
Input string `json:"input"`
}
input := &Input{}
if err := datastar.ReadSignals(r, input); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
return
}
if input.Input == "" {
return // Ignore empty input
}
// Add to list
h.todos.Todos = append(h.todos.Todos, &components.Todo{
Text: input.Input,
Completed: false,
})
// Send updated list via SSE
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(h.todos)); err != nil {
http.Error(w, "Failed to update todos",
http.StatusInternalServerError)
}
}
// ToggleTodo handles toggling a todo's completion status
func (h *Handlers) ToggleTodo(w http.ResponseWriter, r *http.Request) {
// Extract index from URL: /todos/0/toggle
indexStr := chi.URLParam(r, "index")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 || index >= len(h.todos.Todos) {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
// Toggle completed status
h.todos.Todos[index].Completed = !h.todos.Todos[index].Completed
// Send updated list via SSE
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(h.todos)); err != nil {
http.Error(w, "Failed to update todos",
http.StatusInternalServerError)
}
}
// DeleteTodo handles deleting a todo
func (h *Handlers) DeleteTodo(w http.ResponseWriter, r *http.Request) {
indexStr := chi.URLParam(r, "index")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 || index >= len(h.todos.Todos) {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
// Remove from slice
h.todos.Todos = append(h.todos.Todos[:index], h.todos.Todos[index+1:]...)
// Send updated list via SSE
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(h.todos)); err != nil {
http.Error(w, "Failed to update todos",
http.StatusInternalServerError)
}
}Handler pattern for commands:
- Parse request (URL params, form data, signals)
- Validate input
- Modify state (in-memory for now)
- Render updated HTML
- Send via SSE
SSE (Server-Sent Events) intro:
sse := datastar.NewSSE(w, r)
sse.PatchElementTempl(component)This:
- Sets proper SSE headers (
Content-Type: text/event-stream) - Renders component to HTML
- Wraps in SSE format:
data: <div>...</div>\n\n - Sends to client
- Datastar receives, updates DOM
features/todos/routes.go:
package todos
import "github.com/go-chi/chi/v5"
func SetupRoutes(router chi.Router) error {
handlers := NewHandlers()
router.Get("/todos", handlers.TodoPage)
router.Get("/todos/data", handlers.TodosData)
// Command routes
router.Post("/todos/add", handlers.AddTodo)
router.Post("/todos/{index}/toggle", handlers.ToggleTodo)
router.Delete("/todos/{index}", handlers.DeleteTodo)
return nil
}go tool task liveVisit http://localhost:8080/todos
Try:
- Add a new todo (type and press Enter)
- Toggle todos as complete
- Delete todos
What happens:
- Page loads →
data-on-loadtriggers - GET
/todos/data→ Server sends HTML - Datastar swaps HTML into
#todos-container - User clicks checkbox → Datastar sends POST
- Server updates data, sends new HTML
- Datastar swaps updated HTML
What we've built:
- ✅ Interactive UI without writing JavaScript
- ✅ CQRS pattern (commands + query responses)
- ✅ HTML-over-the-wire instead of JSON APIs
- ❌ State is in-memory (lost on refresh)
- ❌ Not persisted to database
- ❌ No real-time sync across tabs
Goal: Store todos in NATS Key-Value store so they survive server restarts.
NATS KV intro:
- Think of it as a key-value database (like Redis)
- Embedded in your Go app (no separate server)
- Built on JetStream (NATS' persistence layer)
features/todos/services/todo_service.go:
package services
import (
"context"
"encoding/json"
"fmt"
"time"
"northstar/features/todos/components"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/nats-io/nats.go/jetstream"
)
type TodoService struct {
kv jetstream.KeyValue
}
// NewTodoService creates a service with NATS KV backend
func NewTodoService(ns *embeddednats.Server) (*TodoService, error) {
// Get NATS client connection
nc, err := ns.Client()
if err != nil {
return nil, fmt.Errorf("failed to create nats client: %w", err)
}
// Get JetStream context
js, err := jetstream.New(nc)
if err != nil {
return nil, fmt.Errorf("failed to create jetstream: %w", err)
}
// Create or update Key-Value bucket
kv, err := js.CreateOrUpdateKeyValue(context.Background(),
jetstream.KeyValueConfig{
Bucket: "todos", // Bucket name (like table name)
Description: "Todo lists", // Human-readable description
Compression: true, // Compress data
TTL: 24 * time.Hour, // Auto-delete after 24h
MaxBytes: 16 * 1024 * 1024, // Max 16MB storage
})
if err != nil {
return nil, fmt.Errorf("failed to create kv: %w", err)
}
return &TodoService{kv: kv}, nil
}
// GetTodos retrieves todos for a user (by userID)
func (s *TodoService) GetTodos(ctx context.Context, userID string) (*components.TodoMVC, error) {
// Try to get existing todos
entry, err := s.kv.Get(ctx, userID)
if err != nil {
if err == jetstream.ErrKeyNotFound {
// First time user - return default todos
return s.createDefaultTodos(), nil
}
return nil, fmt.Errorf("failed to get todos: %w", err)
}
// Deserialize from JSON
mvc := &components.TodoMVC{}
if err := json.Unmarshal(entry.Value(), mvc); err != nil {
return nil, fmt.Errorf("failed to unmarshal todos: %w", err)
}
return mvc, nil
}
// SaveTodos persists todos for a user
func (s *TodoService) SaveTodos(ctx context.Context, userID string, mvc *components.TodoMVC) error {
// Serialize to JSON
data, err := json.Marshal(mvc)
if err != nil {
return fmt.Errorf("failed to marshal todos: %w", err)
}
// Save to NATS KV
if _, err := s.kv.Put(ctx, userID, data); err != nil {
return fmt.Errorf("failed to save todos: %w", err)
}
return nil
}
// AddTodo adds a new todo
func (s *TodoService) AddTodo(mvc *components.TodoMVC, text string) {
mvc.Todos = append(mvc.Todos, &components.Todo{
Text: text,
Completed: false,
})
}
// ToggleTodo toggles a todo's completion status
func (s *TodoService) ToggleTodo(mvc *components.TodoMVC, index int) {
if index >= 0 && index < len(mvc.Todos) {
mvc.Todos[index].Completed = !mvc.Todos[index].Completed
}
}
// DeleteTodo removes a todo
func (s *TodoService) DeleteTodo(mvc *components.TodoMVC, index int) {
if index >= 0 && index < len(mvc.Todos) {
mvc.Todos = append(mvc.Todos[:index], mvc.Todos[index+1:]...)
}
}
func (s *TodoService) createDefaultTodos() *components.TodoMVC {
return &components.TodoMVC{
Todos: []*components.Todo{
{Text: "Learn Go", Completed: true},
{Text: "Learn NATS", Completed: false},
{Text: "Learn Datastar", Completed: false},
},
EditingIdx: -1,
Mode: components.ViewModeAll,
}
}Service pattern:
- Dependencies injected in constructor (
NewTodoService) - Business logic in methods (
AddTodo,ToggleTodo) - Persistence abstracted away (handlers don't know about NATS)
NATS KV operations:
// Create bucket (like CREATE TABLE)
kv, err := js.CreateOrUpdateKeyValue(ctx, config)
// Get value (like SELECT)
entry, err := kv.Get(ctx, "userID")
value := entry.Value() // []byte
// Put value (like INSERT/UPDATE)
_, err := kv.Put(ctx, "userID", data)
// Delete value (like DELETE)
err := kv.Delete(ctx, "userID")We need to identify users. We'll use cookie-based sessions.
features/todos/services/session.go:
package services
import (
"fmt"
"net/http"
"github.com/delaneyj/toolbelt"
"github.com/gorilla/sessions"
)
const sessionName = "todos-session"
// GetOrCreateUserID gets user ID from session, or creates one
func GetOrCreateUserID(store sessions.Store, r *http.Request, w http.ResponseWriter) (string, error) {
sess, err := store.Get(r, sessionName)
if err != nil {
return "", fmt.Errorf("failed to get session: %w", err)
}
// Check if session has ID
if id, ok := sess.Values["user_id"].(string); ok {
return id, nil
}
// Generate new ID
id := toolbelt.NextEncodedID()
sess.Values["user_id"] = id
// Save session (sets cookie)
if err := sess.Save(r, w); err != nil {
return "", fmt.Errorf("failed to save session: %w", err)
}
return id, nil
}features/todos/handlers.go:
package todos
import (
"fmt"
"net/http"
"strconv"
"northstar/features/todos/components"
"northstar/features/todos/pages"
"northstar/features/todos/services"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/starfederation/datastar-go/datastar"
)
type Handlers struct {
service *services.TodoService
sessionStore sessions.Store
}
func NewHandlers(service *services.TodoService, sessionStore sessions.Store) *Handlers {
return &Handlers{
service: service,
sessionStore: sessionStore,
}
}
func (h *Handlers) TodoPage(w http.ResponseWriter, r *http.Request) {
if err := pages.TodoPage("Todos").Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page",
http.StatusInternalServerError)
}
}
func (h *Handlers) TodosData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID from session
userID, err := services.GetOrCreateUserID(h.sessionStore, r, w)
if err != nil {
http.Error(w, "Session error", http.StatusInternalServerError)
return
}
// Load todos from NATS
mvc, err := h.service.GetTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to load todos", http.StatusInternalServerError)
return
}
// Send as HTML fragment
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(mvc)); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}
func (h *Handlers) AddTodo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse input
type Input struct {
Input string `json:"input"`
}
input := &Input{}
if err := datastar.ReadSignals(r, input); err != nil {
http.Error(w, "Invalid input", http.StatusBadRequest)
return
}
if input.Input == "" {
return
}
// Get user ID
userID, err := services.GetOrCreateUserID(h.sessionStore, r, w)
if err != nil {
http.Error(w, "Session error", http.StatusInternalServerError)
return
}
// Load current todos
mvc, err := h.service.GetTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to load todos", http.StatusInternalServerError)
return
}
// Add new todo
h.service.AddTodo(mvc, input.Input)
// Save back to NATS
if err := h.service.SaveTodos(ctx, userID, mvc); err != nil {
http.Error(w, "Failed to save todos", http.StatusInternalServerError)
return
}
// Send updated list
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(mvc)); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}
func (h *Handlers) ToggleTodo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Parse index
indexStr := chi.URLParam(r, "index")
index, err := strconv.Atoi(indexStr)
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
// Get user ID
userID, err := services.GetOrCreateUserID(h.sessionStore, r, w)
if err != nil {
http.Error(w, "Session error", http.StatusInternalServerError)
return
}
// Load todos
mvc, err := h.service.GetTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to load todos", http.StatusInternalServerError)
return
}
// Toggle todo
h.service.ToggleTodo(mvc, index)
// Save to NATS
if err := h.service.SaveTodos(ctx, userID, mvc); err != nil {
http.Error(w, "Failed to save todos", http.StatusInternalServerError)
return
}
// Send updated list
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(mvc)); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}
func (h *Handlers) DeleteTodo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
indexStr := chi.URLParam(r, "index")
index, err := strconv.Atoi(indexStr)
if err != nil {
http.Error(w, "Invalid index", http.StatusBadRequest)
return
}
userID, err := services.GetOrCreateUserID(h.sessionStore, r, w)
if err != nil {
http.Error(w, "Session error", http.StatusInternalServerError)
return
}
mvc, err := h.service.GetTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to load todos", http.StatusInternalServerError)
return
}
h.service.DeleteTodo(mvc, index)
if err := h.service.SaveTodos(ctx, userID, mvc); err != nil {
http.Error(w, "Failed to save todos", http.StatusInternalServerError)
return
}
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(components.TodoList(mvc)); err != nil {
http.Error(w, "Failed to render todos",
http.StatusInternalServerError)
}
}Handler pattern with persistence:
- Get user ID from session
- Load current state from NATS
- Modify state
- Save back to NATS
- Render and send HTML
features/todos/routes.go:
package todos
import (
"northstar/features/todos/services"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
)
func SetupRoutes(router chi.Router, sessionStore sessions.Store, ns *embeddednats.Server) error {
// Create service
service, err := services.NewTodoService(ns)
if err != nil {
return err
}
// Create handlers with dependencies
handlers := NewHandlers(service, sessionStore)
router.Get("/todos", handlers.TodoPage)
router.Get("/todos/data", handlers.TodosData)
router.Post("/todos/add", handlers.AddTodo)
router.Post("/todos/{index}/toggle", handlers.ToggleTodo)
router.Delete("/todos/{index}", handlers.DeleteTodo)
return nil
}Edit router/router.go:
func SetupRoutes(ctx context.Context, router chi.Router, sessionStore *sessions.CookieStore, ns *embeddednats.Server) error {
// ... existing code ...
if err := errors.Join(
todosFeature.SetupRoutes(router, sessionStore, ns), // Pass dependencies
// ... other features ...
); err != nil {
return fmt.Errorf("error setting up routes: %w", err)
}
return nil
}go tool task liveVisit http://localhost:8080/todos
Try:
- Add todos
- Toggle todos
- Refresh page → Todos persist!
- Restart server → Todos still there!
- Open in incognito window → Different todos (different session)
Check NATS:
# List buckets
nats kv ls
# List keys in todos bucket
nats kv ls todos
# Get value for a key
nats kv get todos <key-from-above> --rawWhat we've built:
- ✅ Persistent storage with NATS KV
- ✅ Session-based user identification
- ✅ Data survives server restarts
- ✅ Separation of concerns (service layer)
- ❌ No real-time sync across tabs yet
Goal: When user has multiple tabs open, changes in one tab appear in others instantly.
The pattern:
- Client opens persistent SSE connection
- Server watches NATS key for changes
- Any change triggers HTML update to all connected clients
features/todos/services/todo_service.go (add method):
// WatchTodos watches for changes to a user's todos
func (s *TodoService) WatchTodos(ctx context.Context, userID string) (jetstream.KeyWatcher, error) {
// Watch returns a channel that receives notifications
return s.kv.Watch(ctx, userID)
}What Watch does:
watcher, err := kv.Watch(ctx, "user123")
// Returns channel: watcher.Updates()
// Whenever key "user123" changes...
for update := range watcher.Updates() {
// update.Value() contains new data
// Process and send to client
}features/todos/handlers.go (replace TodosData):
func (h *Handlers) TodosData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user ID
userID, err := services.GetOrCreateUserID(h.sessionStore, r, w)
if err != nil {
http.Error(w, "Session error", http.StatusInternalServerError)
return
}
// Load initial todos
mvc, err := h.service.GetTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to load todos", http.StatusInternalServerError)
return
}
// Create SSE writer
sse := datastar.NewSSE(w, r)
// Setup NATS watcher
watcher, err := h.service.WatchTodos(ctx, userID)
if err != nil {
http.Error(w, "Failed to watch todos", http.StatusInternalServerError)
return
}
defer watcher.Stop()
// Event loop - stays open until client disconnects
for {
select {
case <-ctx.Done():
// Client disconnected, exit
return
case entry := <-watcher.Updates():
// NATS notified us of a change!
if entry == nil {
continue
}
// Deserialize updated todos
if err := json.Unmarshal(entry.Value(), mvc); err != nil {
http.Error(w, "Failed to parse todos",
http.StatusInternalServerError)
return
}
// Render and send HTML fragment
if err := sse.PatchElementTempl(components.TodoList(mvc)); err != nil {
// If send fails, client probably disconnected
return
}
}
}
}What this does:
- Initial load: Get todos from NATS
- Setup watcher: Subscribe to changes on this user's key
- Event loop: Run forever, listening for:
ctx.Done(): Client closed tab → exit gracefullywatcher.Updates(): NATS detected change → send new HTML
- Send updates: Whenever data changes, render + send via SSE
The magic:
for {
case entry := <-watcher.Updates():
// Watcher blocks here until change happens
// When change happens, this executes:
json.Unmarshal(entry.Value(), mvc) // Deserialize
sse.PatchElementTempl(components.TodoList(mvc)) // Send HTML
}Add to imports in features/todos/handlers.go:
import (
// ... existing imports ...
"encoding/json"
)go tool task liveTesting real-time sync:
- Open
http://localhost:8080/todosin two browser tabs - In Tab 1: Add a todo
- Watch Tab 2: Todo appears automatically!
- In Tab 2: Toggle a todo
- Watch Tab 1: Updates instantly!
What's happening:
Tab 1 NATS Tab 2
| | |
|-- POST /todos/add ----------> | |
| (Handler saves to NATS) | |
| |-- Notify watchers ->|
|<-- SSE: New HTML -------------| |
| |-- Notify watchers ->|
| |<-- SSE: New HTML ---|
Flow:
- Tab 1 sends POST request
- Handler saves to NATS:
kv.Put(userID, data) - NATS detects key
userIDchanged - NATS sends notification to all watchers of this key
- Tab 1's watcher receives notification → sends HTML to Tab 1
- Tab 2's watcher receives notification → sends HTML to Tab 2
- Both tabs update simultaneously!
What we've built:
- ✅ Real-time sync across browser tabs
- ✅ Persistent SSE connections
- ✅ NATS as message bus
- ✅ Complete CQRS pattern
- ✅ Full-featured todo app!
Now that you've built it, let's understand how all the layers work together.
Command Query Responsibility Segregation:
Commands (writes):
User Action → POST/PUT/DELETE → Handler → Service → NATS.Put()
↓
Triggers watchers
Queries (reads):
Page Load → GET (SSE) → Watcher.Updates() → Render HTML → Send SSE
↑
└── Triggered by NATS notifications
Key insight: Commands and queries are separate concerns:
- Commands modify state (don't return HTML immediately)
- Queries respond to state changes (via watchers)
- This separation enables real-time sync
Client Server
| |
|-- GET /todos/data (initial) ---------->|
| |-- Setup watcher
| |-- Load initial data
|<-- SSE: Initial HTML -------------------|
| |
| [Connection stays open] | [Loop waiting for updates]
| |
| [User clicks in another tab] |
| |<-- NATS notification
|<-- SSE: Updated HTML -------------------|
| |
| [User closes tab] |
|-- Connection closed ------------------->|-- ctx.Done() fires
| |-- Exit loop
| |-- watcher.Stop()
SSE vs WebSocket:
- SSE: Server → Client only (unidirectional)
- WebSocket: Both directions (bidirectional)
- For hypermedia: SSE is simpler and sufficient
- Commands use regular HTTP POST/PUT/DELETE
- Updates stream via SSE
Your App Process
├── HTTP Server (port 8080)
│ ├── Handler 1 → Service → NATS Client
│ ├── Handler 2 → Service → NATS Client
│ └── Handler 3 → Service → NATS Client
└── Embedded NATS Server (port 4222)
└── JetStream
└── KV Bucket: "todos"
├── key: "user_abc123"
├── key: "user_def456"
└── key: "user_ghi789"
Benefits of embedded NATS:
- No separate database to manage
- No connection pooling needed
- No network latency (same process)
- Still persistent (data on disk)
- Built-in pub/sub for real-time
Complete flow for "User toggles todo":
┌─────────┐
│ Browser │
└────┬────┘
│ 1. Click checkbox (data-on-click)
↓
┌────────────┐
│ Datastar │
└─────┬──────┘
│ 2. POST /todos/0/toggle
↓
┌─────────────┐
│ Chi Router │
└──────┬──────┘
│ 3. Route to ToggleTodo handler
↓
┌─────────────────┐
│ ToggleTodo │
│ Handler │
└────┬────────────┘
│ 4. Get user ID from session
│ 5. Load todos from NATS
↓
┌──────────────┐
│ TodoService │
└────┬─────────┘
│ 6. Toggle logic
│ 7. kv.Put(userID, data)
↓
┌────────────────┐
│ NATS JetStream │
│ KV Store │
└────┬───────────┘
│ 8. Broadcast change notification
├────────────────┬─────────────────┐
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│Watcher 1│ │Watcher 2│ │Watcher 3│
│(Tab 1) │ │(Tab 2) │ │(Tab 3) │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
│ 9. watcher.Updates() receives notification
↓ ↓ ↓
┌─────────────────────────────────────────┐
│ 10. Deserialize → Render → Send via SSE │
└────┬────────────┬─────────────┬─────────┘
↓ ↓ ↓
┌─────────┐ ┌─────────┐ ┌─────────┐
│Browser 1│ │Browser 2│ │Browser 3│
└─────────┘ └─────────┘ └─────────┘
│ │ │
│ 11. Datastar updates DOM
└────────────┴──────────────┘
↓
All browsers show
updated checkbox!
You've now built a complete real-time todo application! Here's what to explore next:
-
Inline editing
- Click todo text to edit
- Use
data-on-click__outsideto cancel - See actual implementation in
features/index/components/todo.templ:157-173
-
View filtering (All/Active/Completed)
- Add mode buttons
- Filter in template conditionally
- See
features/index/components/todo.templ:113-125
-
Bulk operations
- Toggle all todos
- Clear completed
- See
features/index/services/todo_service.go:90-106
-
Optimistic updates
- Update UI immediately
- Revert on server error
-
Loading indicators
- Use
data-indicatorattribute - Show spinners during requests
- Use
-
Form validation
- Server-side validation
- Return form with errors
-
Infinite scroll
- Use
data-intersectsattribute - Load more as user scrolls
- Use
The actual todo feature in this repo (features/index/) includes:
- Inline editing with
data-bind-input - View mode filtering
- Toggle all functionality
- Better error handling
- Loading states
- DaisyUI styling
Compare your implementation with the real one to see additional patterns!
- Datastar Documentation - Complete attribute reference
- NATS JetStream Guide - Deep dive into persistence
- Templ Guide - Template syntax and patterns
- HTMX Essays - Hypermedia philosophy
You've learned:
- ✅ Go web routing with Chi
- ✅ Type-safe templating with Templ
- ✅ Hypermedia-driven architecture
- ✅ CQRS pattern (Command-Query separation)
- ✅ Server-Sent Events for real-time updates
- ✅ NATS Key-Value for persistence
- ✅ Real-time sync across clients
- ✅ Building without client-side JavaScript
You now understand the full Northstar stack!
If you want to build a simpler "greeting" feature to practice, here's a quick starter:
mkdir -p features/greeting/pagesfeatures/greeting/pages/greeting.templ:
package pages
import (
"northstar/features/common/layouts"
"northstar/features/common/components"
)
// Signals structure - will be JSON in the frontend
type GreetingSignals struct {
Name string `json:"name"`
Greeting string `json:"greeting"`
}
// Main page component
templ GreetingPage() {
@layouts.Base("Greeting") {
@components.Navigation(components.PageIndex)
<article class="prose mx-auto m-2">
<h1>Greeting Demo</h1>
<div id="greeting-container"
data-on-load="@get('/greeting/data')">
<!-- Content will be loaded here -->
</div>
</article>
}
}
// The interactive greeting component
templ GreetingContent(signals GreetingSignals) {
<div id="greeting-container"
data-signals={ templ.JSONString(signals) }
class="flex flex-col gap-4">
<!-- Input bound to $name signal -->
<input
type="text"
placeholder="Enter your name"
class="input input-bordered"
data-bind-input="$name"
/>
<!-- Display greeting (reactive) -->
<div class="text-2xl">
<span data-text="$greeting"></span>
</div>
<!-- Button to fetch new greeting -->
<button
class="btn btn-primary"
data-on-click="@post('/greeting/generate')">
Generate Greeting
</button>
</div>
}Understanding the template:
- Package and imports: Templ files start with Go package declaration
- Signals struct: Defines reactive state shape (becomes JSON)
- GreetingPage: The full page with layout
- GreetingContent: The interactive part
- data-on-load: Fetches initial data when element loads
- data-signals: Initializes reactive state
- data-bind-input: Two-way binds input to
$namesignal - data-text: One-way binds text content to
$greetingsignal - data-on-click: Triggers action on click
go tool templ generateThis creates greeting_templ.go with compiled template functions.
features/greeting/handlers.go:
package greeting
import (
"fmt"
"net/http"
"northstar/features/greeting/pages"
"github.com/Jeffail/gabs/v2"
"github.com/starfederation/datastar-go/datastar"
)
type Handlers struct{}
func NewHandlers() *Handlers {
return &Handlers{}
}
// Serves the initial page
func (h *Handlers) GreetingPage(w http.ResponseWriter, r *http.Request) {
// Render the page template
if err := pages.GreetingPage().Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
// Serves initial data via SSE
func (h *Handlers) GreetingData(w http.ResponseWriter, r *http.Request) {
// Create initial signals
signals := pages.GreetingSignals{
Name: "World",
Greeting: "Hello, World!",
}
// Create SSE writer
sse := datastar.NewSSE(w, r)
// Send the GreetingContent template as HTML fragment
if err := sse.PatchElementTempl(pages.GreetingContent(signals)); err != nil {
http.Error(w, "Failed to send data", http.StatusInternalServerError)
}
}
// Generates new greeting based on current name
func (h *Handlers) GenerateGreeting(w http.ResponseWriter, r *http.Request) {
// Parse form data to get current $name value
// Datastar automatically sends current signals with requests
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
name := r.FormValue("name")
if name == "" {
name = "Friend"
}
// Generate new greeting
greetings := []string{
"Hello, %s!",
"Hi there, %s!",
"Greetings, %s!",
"Welcome, %s!",
"Hey, %s!",
}
greeting := fmt.Sprintf(greetings[len(name)%len(greetings)], name)
// Create signal update
update := gabs.New()
update.Set(greeting, "greeting")
// Send SSE response with signal update
sse := datastar.NewSSE(w, r)
if err := sse.MarshalAndPatchSignals(update); err != nil {
http.Error(w, "Failed to update", http.StatusInternalServerError)
}
}Understanding the handlers:
-
GreetingPage:
- Serves the full HTML page on first load
- Just renders the template directly
-
GreetingData:
- Called via SSE when page loads (
data-on-load) - Creates initial signals
- Sends HTML fragment via
PatchElementTempl - This replaces
<div id="greeting-container">content
- Called via SSE when page loads (
-
GenerateGreeting:
- Called on button click
- Reads current state from form data
- Computes new greeting
- Sends only the changed signal (not full HTML)
- Datastar automatically updates
data-text="$greeting"
SSE Response Types:
// Method 1: Send HTML fragment (replaces element content)
sse.PatchElementTempl(pages.SomeTemplate())
// Method 2: Send signal updates (updates reactive state)
update := gabs.New()
update.Set(value, "signalName")
sse.MarshalAndPatchSignals(update)
// Method 3: Send multiple fragments
sse.PatchElementTempl(template1, template2)features/greeting/routes.go:
package greeting
import (
"github.com/go-chi/chi/v5"
)
func SetupRoutes(router chi.Router) error {
handlers := NewHandlers()
// Page route - serves full HTML
router.Get("/greeting", handlers.GreetingPage)
// SSE routes - serve data/updates
router.Get("/greeting/data", handlers.GreetingData)
router.Post("/greeting/generate", handlers.GenerateGreeting)
return nil
}Route patterns:
GET /feature- Full page (first load)GET /feature/data- Initial data via SSEPOST /feature/action- Actions that modify statePUT /feature/id- UpdatesDELETE /feature/id- Deletions
Edit router/router.go:
import (
greetingFeature "northstar/features/greeting"
)
func SetupRoutes(ctx context.Context, router chi.Router, sessionStore *sessions.CookieStore, ns *embeddednats.Server) error {
// ... existing code ...
if err := errors.Join(
indexFeature.SetupRoutes(router, sessionStore, ns),
counterFeature.SetupRoutes(router, sessionStore),
greetingFeature.SetupRoutes(router), // Add this line
); err != nil {
return fmt.Errorf("error setting up routes: %w", err)
}
return nil
}Edit features/common/components/navigation.templ:
const (
// ... existing pages ...
PageGreeting PageType = iota + 100
)
// In the Navigation template, add:
<a href="/greeting" class={navClass(currentPage == PageGreeting)}>Greeting</a>go tool task liveNavigate to http://localhost:8080/greeting
What happens:
- Browser requests
/greeting→ Server sends full HTML page - Page loads,
data-on-loadtriggers → Request to/greeting/data - Server sends HTML fragment via SSE → Replaces
#greeting-container - Type in input →
$namesignal updates automatically - Click button → POST to
/greeting/generate→ Server sends signal update $greetingupdates →data-textauto-updates DOM
The greeting example used in-memory state. Real apps need persistence. Northstar uses NATS JetStream's Key-Value store.
NATS provides a key-value store similar to Redis:
// Store data
kv.Put(ctx, "user:123", []byte(`{"name":"Alice"}`))
// Retrieve data
entry, err := kv.Get(ctx, "user:123")
value := entry.Value() // []byte
// Watch for changes
watcher, err := kv.Watch(ctx, "user:123")
for update := range watcher.Updates() {
// Reacts to changes in real-time
}Benefits:
- Embedded: No separate database to manage
- Real-time: Watch for changes
- Automatic: Handles serialization
- TTL support: Auto-expire old data
Let's create a notes feature with NATS persistence:
features/notes/services/note_service.go:
package services
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/delaneyj/toolbelt/embeddednats"
"github.com/gorilla/sessions"
"github.com/nats-io/nats.go/jetstream"
)
type Note struct {
ID string `json:"id"`
Text string `json:"text"`
CreatedAt time.Time `json:"createdAt"`
}
type NoteService struct {
kv jetstream.KeyValue
store sessions.Store
}
func NewNoteService(ns *embeddednats.Server, store sessions.Store) (*NoteService, error) {
// Get NATS client
nc, err := ns.Client()
if err != nil {
return nil, fmt.Errorf("error creating nats client: %w", err)
}
// Get JetStream context
js, err := jetstream.New(nc)
if err != nil {
return nil, fmt.Errorf("error creating jetstream: %w", err)
}
// Create or update Key-Value bucket
kv, err := js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{
Bucket: "notes", // Bucket name
Description: "User notes", // Description
Compression: true, // Enable compression
TTL: 24 * time.Hour, // Expire after 24 hours
MaxBytes: 16 * 1024 * 1024, // Max 16MB
})
if err != nil {
return nil, fmt.Errorf("error creating kv: %w", err)
}
return &NoteService{
kv: kv,
store: store,
}, nil
}
// Get all notes for a user
func (s *NoteService) GetNotes(ctx context.Context, userID string) ([]*Note, error) {
// List all keys for this user
keys, err := s.kv.Keys(ctx)
if err != nil {
return nil, fmt.Errorf("error listing keys: %w", err)
}
var notes []*Note
prefix := fmt.Sprintf("user:%s:", userID)
for _, key := range keys {
if !strings.HasPrefix(key, prefix) {
continue
}
// Get value
entry, err := s.kv.Get(ctx, key)
if err != nil {
continue // Skip errors
}
// Deserialize
var note Note
if err := json.Unmarshal(entry.Value(), ¬e); err != nil {
continue
}
notes = append(notes, ¬e)
}
// Sort by created time
sort.Slice(notes, func(i, j int) bool {
return notes[i].CreatedAt.After(notes[j].CreatedAt)
})
return notes, nil
}
// Add a new note
func (s *NoteService) AddNote(ctx context.Context, userID, text string) (*Note, error) {
note := &Note{
ID: generateID(),
Text: text,
CreatedAt: time.Now(),
}
// Serialize
data, err := json.Marshal(note)
if err != nil {
return nil, fmt.Errorf("error marshaling: %w", err)
}
// Store with key pattern: user:123:note:456
key := fmt.Sprintf("user:%s:note:%s", userID, note.ID)
if _, err := s.kv.Put(ctx, key, data); err != nil {
return nil, fmt.Errorf("error storing: %w", err)
}
return note, nil
}
// Delete a note
func (s *NoteService) DeleteNote(ctx context.Context, userID, noteID string) error {
key := fmt.Sprintf("user:%s:note:%s", userID, noteID)
return s.kv.Delete(ctx, key)
}
// Watch for changes (for real-time updates)
func (s *NoteService) WatchNotes(ctx context.Context, userID string) (jetstream.KeyWatcher, error) {
pattern := fmt.Sprintf("user:%s:*", userID)
return s.kv.Watch(ctx, pattern)
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}Key patterns:
- Namespacing: Use patterns like
user:123:note:456to organize keys - Serialization: Marshal/unmarshal to JSON
- Error handling: Always check errors
- Context: Pass context for cancellation
- Watching: Use watchers for real-time updates
func (h *Handlers) NotesData(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := getUserID(r)
// Get notes from service
notes, err := h.noteService.GetNotes(ctx, userID)
if err != nil {
http.Error(w, "Failed to get notes", http.StatusInternalServerError)
return
}
// Render template with notes
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(pages.NotesList(notes)); err != nil {
http.Error(w, "Failed to render", http.StatusInternalServerError)
}
}For features that need real-time sync across clients:
func (h *Handlers) NotesStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID := getUserID(r)
// Create watcher
watcher, err := h.noteService.WatchNotes(ctx, userID)
if err != nil {
http.Error(w, "Failed to watch", http.StatusInternalServerError)
return
}
defer watcher.Stop()
sse := datastar.NewSSE(w, r)
// Send initial state
notes, _ := h.noteService.GetNotes(ctx, userID)
sse.PatchElementTempl(pages.NotesList(notes))
// Stream updates
for {
select {
case <-ctx.Done():
return
case update := <-watcher.Updates():
// Re-fetch and send updated list
notes, _ := h.noteService.GetNotes(ctx, userID)
sse.PatchElementTempl(pages.NotesList(notes))
}
}
}This enables:
- Multiple browser tabs staying in sync
- Multi-user real-time collaboration
- Automatic UI updates when data changes
Datastar makes forms simple with automatic form data submission:
templ ContactForm() {
<form data-on-submit="@post('/contact/submit')">
<input name="email" type="email" required
class="input input-bordered"/>
<textarea name="message" required
class="textarea textarea-bordered"></textarea>
<button type="submit" class="btn btn-primary">Send</button>
</form>
}Handler automatically receives form data:
func (h *Handlers) SubmitContact(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
email := r.FormValue("email")
message := r.FormValue("message")
// Process form...
// Send success message
sse := datastar.NewSSE(w, r)
sse.PatchElementTempl(pages.SuccessMessage())
}Show inline validation errors:
type FormState struct {
Email string `json:"email"`
Message string `json:"message"`
Errors map[string]string `json:"errors"`
}
templ ContactFormWithErrors(state FormState) {
<form data-signals={ templ.JSONString(state) }
data-on-submit="@post('/contact/submit')">
<div>
<input name="email"
type="email"
data-bind-input="$email"
class="input input-bordered"/>
if state.Errors["email"] != "" {
<div class="text-error text-sm">{state.Errors["email"]}</div>
}
</div>
<div>
<textarea name="message"
data-bind-input="$message"
class="textarea"></textarea>
if state.Errors["message"] != "" {
<div class="text-error text-sm">{state.Errors["message"]}</div>
}
</div>
<button type="submit" class="btn">Send</button>
</form>
}Handler returns errors:
func (h *Handlers) SubmitContact(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
state := FormState{
Email: r.FormValue("email"),
Message: r.FormValue("message"),
Errors: make(map[string]string),
}
// Validate
if !isValidEmail(state.Email) {
state.Errors["email"] = "Invalid email address"
}
if len(state.Message) < 10 {
state.Errors["message"] = "Message too short"
}
if len(state.Errors) > 0 {
// Send form back with errors
sse := datastar.NewSSE(w, r)
sse.PatchElementTempl(pages.ContactFormWithErrors(state))
return
}
// Process valid form...
}Show loading indicators during async operations:
templ SaveButton() {
<button
class="btn btn-primary"
data-on-click="@post('/save')"
data-indicator="saving"
data-attrs-disabled="$saving">
<span data-show="!$saving">Save</span>
<span data-show="$saving" class="loading loading-spinner"></span>
</button>
}How it works:
data-indicator="saving"creates$savingsignal- Automatically set to
trueduring request - Set to
falsewhen request completes data-attrs-disableddisables button while savingdata-showconditionally shows elements
Update UI immediately, revert on error:
templ TodoItem(todo Todo, index int) {
<li id={"todo-" + fmt.Sprint(index)}>
<input
type="checkbox"
checked?={todo.Completed}
data-on-change={ fmt.Sprintf(`
$todos[%d].completed = !$todos[%d].completed;
@post('/todos/%d/toggle')
`, index, index, index) }
/>
<span>{todo.Text}</span>
</li>
}Flow:
- User clicks checkbox
- Signal updates immediately (optimistic)
- Request sent to server
- If server succeeds: No change needed
- If server fails: Handler sends corrected state
Load more items as user scrolls:
templ ItemList(items []Item, hasMore bool, page int) {
<div id="items">
for _, item := range items {
<div>{item.Name}</div>
}
if hasMore {
<div
data-intersects="@get('/items?page=' + ($page + 1))"
data-signals={ fmt.Sprintf("{page: %d}", page) }>
<div class="loading loading-spinner"></div>
</div>
}
</div>
}How it works:
data-intersects: Triggers when element becomes visible- Automatically loads next page
- Server appends new items to list
- Updates
$pagesignal
Search as user types, with debouncing:
templ SearchBox() {
<input
type="text"
placeholder="Search..."
data-bind-input="$query"
data-on-input.debounce_500ms="@get('/search?q=' + $query)"
class="input input-bordered"
/>
<div id="results"></div>
}Modifiers:
.debounce_500ms: Wait 500ms after typing stops.throttle_1000ms: Max once per 1000ms.once: Only trigger once
Handler:
func (h *Handlers) Search(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
results := performSearch(query)
sse := datastar.NewSSE(w, r)
sse.PatchElementTempl(pages.SearchResults(results))
}Show modals via server responses:
templ ConfirmDialog(message string) {
<dialog id="confirm-modal" open class="modal">
<div class="modal-box">
<p>{message}</p>
<div class="modal-action">
<button
class="btn"
data-on-click="document.getElementById('confirm-modal').close()">
Cancel
</button>
<button
class="btn btn-error"
data-on-click="@post('/confirm') && document.getElementById('confirm-modal').close()">
Confirm
</button>
</div>
</div>
</dialog>
}Show modal from handler:
func (h *Handlers) DeleteItem(w http.ResponseWriter, r *http.Request) {
sse := datastar.NewSSE(w, r)
// Send modal to client
sse.PatchElementTempl(pages.ConfirmDialog("Delete this item?"))
}# Start dev server with live reload
go tool task live
# Server runs on http://localhost:8080
# Changes to .go or .templ files trigger rebuildHow live reload works:
- Air watches for file changes
- Recompiles Go code
- Restarts server
- Sends reload signal to browser
- Browser auto-refreshes
# Run without live reload
go tool task run
# Build production binary
go tool task build
# Start debugger
go tool task debug
# Generate templ files
go tool templ generate
# Tidy dependencies
go mod tidyFiles that trigger rebuild:
**/*.go- Go source files**/*.templ- Template filesgo.mod- Dependencies
Files ignored:
**/.*- Hidden files**/*_test.go- Test filesdata/- NATS storagetmp/- Build artifacts
- Open project in VS Code
- Set breakpoint in code
- Press F5 or use "Debug Main" configuration
- Debugger attaches, execution pauses at breakpoint
# Start debugger
go tool task debug
# In delve prompt:
(dlv) break main.main
(dlv) continue
(dlv) print variableName
(dlv) next
(dlv) quitimport "log/slog"
// Different log levels
slog.Debug("debug message", "key", value)
slog.Info("info message", "key", value)
slog.Warn("warning message", "error", err)
slog.Error("error message", "error", err)
// Structured logging
slog.Info("user action",
"user_id", userID,
"action", "login",
"timestamp", time.Now())View logs:
# Watch logs in terminal where server is running
# Logs are JSON formatted for easy parsingbrew install nats-io/nats-tools/nats# List all buckets
nats kv ls
# List keys in a bucket
nats kv ls todos
# Get value for a key
nats kv get todos user:123 --raw
# Put value
nats kv put todos user:123 '{"name":"Alice"}'
# Watch for changes
nats kv watch todos
# Delete key
nats kv rm todos user:123
# Get bucket info
nats kv info todos- Open DevTools (F12)
- Go to Network tab
- Filter by "EventStream" type
- Click on connection to see:
- Request headers
- SSE events received
- Timing information
// In browser console:
// View all signals
$datastar.signals
// Watch signal changes
$datastar.signals.count
// (type something, watch it update)
// Manually trigger action
$datastar.actions.post('/some/endpoint')- Test on page first load
- Test after interaction
- Test with browser DevTools network throttling
- Test error cases (disconnect, invalid input)
- Test with multiple browser tabs open
- Test browser back/forward buttons
Use curl to test SSE:
curl -N -H "Accept: text/event-stream" \
http://localhost:8080/counter/dataOutput:
data: <div id="container">...
data: signal {"count": 0}
Cause: Forgot to regenerate after editing .templ file
Solution:
go tool templ generate
# Or just wait for Air to detect changeChecklist:
- Is feature registered in
router/router.go? - Is route defined in feature's
routes.go? - Does URL match route pattern?
- Is HTTP method correct (GET/POST/etc)?
Causes:
- Error in handler (check logs)
- Context cancelled too early
- Proxy/load balancer timeout
Debug:
func (h *Handlers) SomeSSE(w http.ResponseWriter, r *http.Request) {
defer slog.Info("SSE connection closed")
sse := datastar.NewSSE(w, r)
if err := sse.PatchElementTempl(template); err != nil {
slog.Error("SSE error", "error", err)
return
}
}Checklist:
- Is NATS server running? (Check logs)
- Is bucket created? (
nats kv ls) - Are you saving correctly? (Check for errors)
- Is TTL too short? (Check bucket config)
Causes:
- Typo in signal name (
$countvs$counter) - Signal not initialized in
data-signals - Server not sending correct SSE format
Debug:
<!-- Add debug display -->
<pre data-text="JSON.stringify($datastar.signals, null, 2)"></pre>- Build the greeting feature (from this tutorial)
- Study the counter feature (
features/counter/)- Simple handlers
- Session storage
- Multiple signals
- Study the todo feature (
features/index/)- NATS persistence
- Complex interactions
- Form handling
- Build your own feature
- Start simple (no persistence)
- Add persistence with NATS
- Add real-time updates
- Go by Example - Learn Go syntax
- Effective Go - Best practices
- Go Web Examples - Web programming patterns
- Datastar Documentation - Official docs
- Datastar Examples - Interactive examples
- Datastar GitHub - Source code
- Templ Guide - Official guide
- Templ Examples - Syntax reference
- NATS Documentation - Complete docs
- NATS by Example - Code examples
- JetStream Guide - Persistence
Beginner:
- Dice roller (click to roll)
- Color picker (select color, show preview)
- Calculator (basic math operations)
- Timer/stopwatch (start/stop/reset)
Intermediate:
- Note-taking app (CRUD with NATS)
- Poll/voting system (real-time results)
- Chat room (multi-user, real-time)
- Image upload (file handling)
Advanced:
- Markdown editor with live preview
- Kanban board (drag-and-drop)
- Multi-player game (real-time sync)
- Dashboard with charts (data visualization)
- Fork the repository
- Create a feature branch
- Add your feature in
features/ - Test thoroughly
- Submit pull request
PR checklist:
- Code follows existing patterns
- Templates use consistent styling
- Feature is self-contained
- No breaking changes to existing features
- README updated if needed
You now understand:
- The hypermedia pattern and how it differs from SPAs
- Northstar's architecture and how pieces fit together
- How to build features from scratch
- How to use NATS for state persistence
- Advanced patterns for common scenarios
- Development workflow and debugging
Key takeaways:
- Server controls UI: Business logic stays on server
- HTML over the wire: Send HTML fragments, not JSON
- Reactive updates: Datastar handles DOM updates automatically
- Real-time built-in: SSE + NATS make real-time easy
- Feature-oriented: Organize by feature, not layer
The Northstar philosophy:
- Simplicity over complexity
- Server-side rendering over client-side frameworks
- Progressive enhancement over JavaScript-heavy apps
- Developer experience matters
Now go build something great!
| Attribute | Purpose | Example |
|---|---|---|
data-signals |
Initialize reactive state | data-signals="{count: 0}" |
data-text |
Bind element text | data-text="$count" |
data-bind-input |
Two-way bind input | data-bind-input="$name" |
data-on-click |
Handle click | data-on-click="@post('/api')" |
data-on-load |
Run on element load | data-on-load="@get('/data')" |
data-on-submit |
Handle form submit | data-on-submit="@post('/save')" |
data-show |
Conditional display | data-show="$count > 0" |
data-indicator |
Loading state | data-indicator="saving" |
data-attrs-* |
Bind attribute | data-attrs-disabled="$saving" |
sse := datastar.NewSSE(w, r)
// Send HTML fragment
sse.PatchElementTempl(template)
// Send signal update
sse.MarshalAndPatchSignals(signalsMap)
// Execute JavaScript
sse.ExecuteScript("alert('hello')")// Create/update bucket
kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: "name",
TTL: time.Hour,
})
// Put value
kv.Put(ctx, "key", []byte("value"))
// Get value
entry, err := kv.Get(ctx, "key")
value := entry.Value()
// Delete
kv.Delete(ctx, "key")
// Watch for changes
watcher, err := kv.Watch(ctx, "pattern")
for update := range watcher.Updates() {
// Handle update
}// Basic template
templ Hello(name string) {
<div>Hello, {name}!</div>
}
// Template with signals
templ Counter(count int) {
<div data-signals={ templ.JSONString(map[string]any{
"count": count,
}) }>
<div data-text="$count"></div>
</div>
}
// Conditional rendering
templ Item(item Item) {
if item.Active {
<div>Active</div>
} else {
<div>Inactive</div>
}
}
// Loop rendering
templ List(items []Item) {
for _, item := range items {
<div>{item.Name}</div>
}
}