Skip to content

Instantly share code, notes, and snippets.

@derekr
Created October 12, 2025 16:50
Show Gist options
  • Select an option

  • Save derekr/9c33797677c74218b8f51765626fca8d to your computer and use it in GitHub Desktop.

Select an option

Save derekr/9c33797677c74218b8f51765626fca8d to your computer and use it in GitHub Desktop.

Northstar Tutorial: Building Modern Web Apps with Go and Datastar

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.

Table of Contents

  1. Understanding the Stack
  2. Core Concepts
  3. Project Architecture
  4. Building Your First Feature
  5. Working with State and Persistence
  6. Advanced Patterns
  7. Development Workflow

Understanding the Stack

What is Northstar?

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)

The Technologies

Go (Backend Language)

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 (Message System & Storage)

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 (Reactive Frontend)

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 loads
  • data-on-click: Run action on click
  • data-signals: Define reactive state
  • data-text: Bind element text to a signal (reactive variable)
  • data-bind-input: Two-way bind input to state

Templ (HTML Templating)

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 (HTTP Router)

Chi is a lightweight HTTP router:

  • Middleware support: Logging, recovery, etc.
  • RESTful routing: Clean URL patterns
  • Sub-routing: Group related routes

Core Concepts

1. The Hypermedia Pattern

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

2. Server-Sent Events (SSE)

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

3. Signals (Reactive State)

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-text updates 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 $count

4. The Feature Pattern

Northstar 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

Project Architecture

Directory Structure

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)

Request Flow

Let's trace a request through the system:

  1. User clicks "Increment Global" button

    <button data-on-click="@post('/counter/increment/global')">
  2. Datastar sends POST request to /counter/increment/global

  3. Chi router matches route → calls handler

    router.Post("/counter/increment/global", handlers.IncrementGlobal)
  4. 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)
    }
  5. Server sends SSE response with signal update

    data: signal {"global": 42}
    
  6. Datastar updates DOM - all elements with data-text="$global" update automatically

Application Startup

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

Deep Dive: The Todo Feature

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.

Overview: What the Todo App Does

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)

Architecture Overview

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.

File Structure

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

Step-by-Step: How Todo Works

Step 1: Initial Page Load

What happens when you visit http://localhost:8080/

1.1: Route Matching

features/index/routes.go:19:

router.Get("/", handlers.IndexPage)

Chi router sees GET / and calls IndexPage handler.

1.2: Page 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:

  1. Calls the IndexPage template
  2. Renders it to the http.ResponseWriter
  3. Sends full HTML page to browser

Key concept: This is a traditional page render. No JSON, no client-side rendering - just HTML.

1.3: Page Template

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:

  1. @layouts.Base(title): Wraps content in base layout (HTML structure, CSS, Datastar script)
  2. @components.Navigation: Renders navigation bar
  3. <div id="todos-container">: Empty container for todos
  4. data-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-stream header (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.


Step 2: SSE Connection & Initial Data

Browser makes request: GET /api/todos (with SSE headers)

2.1: Route Matching

features/index/routes.go:23:

apiRouter.Route("/todos", func(todosRouter chi.Router) {
    todosRouter.Get("/", handlers.TodosSSE)
    // ... other routes
})

Routes to TodosSSE handler.

2.2: TodosSSE Handler - The Real-Time Engine

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:

2.2.1: Get Session MVC

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:

  1. Get session ID: Uses gorilla/sessions to get/set a cookie with unique user ID
  2. Query NATS: Try to get todos for this session ID from NATS KV store
  3. First-time user?: Create default todos, save to NATS
  4. Returning user?: Deserialize their saved todos
  5. Return: Session ID + todo data

NATS Key structure: sessionID (e.g., "user_abc123") → JSON blob of todos

2.2.2: Setup Watcher
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 sessionID changes in NATS...
  • A message appears on watcher.Updates() channel
  • This enables real-time sync across browser tabs!
2.2.3: The Event Loop
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):

  1. Waits for either:
    • ctx.Done(): Client closed connection → exit
    • watcher.Updates(): NATS says data changed → send update
  2. When data changes:
    • Deserialize from NATS
    • Render TodosMVCView component
    • Send HTML fragment via SSE
  3. Repeat

SSE Protocol:

Server sends:

data: <div id="todos-container">...full HTML...</div>

Browser receives this, Datastar:

  1. Parses the HTML
  2. Finds element with id="todos-container"
  3. Replaces its contents with new HTML
2.2.4: Initial Render

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!


Step 3: User Interaction - Toggling a Todo

User clicks checkbox to complete a todo

3.1: The Template

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 (from data-indicator)

3.2: Route Matching

features/index/routes.go:28-29:

todoRouter.Route("/{idx}", func(todoRouter chi.Router) {
    todoRouter.Post("/toggle", handlers.ToggleTodo)
    // ...
})

Routes POST /api/todos/0/toggleToggleTodo handler

URL parameter: {idx} captures 0 from URL

3.3: ToggleTodo Handler

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:

  1. Get current state: Fetch todos from NATS (via session)
  2. Parse index: Extract 0 from URL (/api/todos/0/toggle)
  3. Toggle business logic: Call service method
  4. Save to NATS: Persist changes

Important: Handler doesn't send HTML response! It just saves to NATS and returns.

3.4: ToggleTodo Service Logic

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 Completed boolean

MVC modification: Changes happen to the mvc pointer in memory.

3.5: Save to NATS

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:

  1. Marshal todos to JSON: {"todos":[...], "editingIdx":-1, "mode":0}
  2. Put into NATS: kv.Put(ctx, "user_abc123", jsonBytes)
  3. NATS broadcasts change notification!

3.6: The Real-Time Magic

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:

  1. kv.Put() saves to NATS
  2. NATS detects key changed
  3. NATS sends notification to all watchers (including our SSE connection)
  4. Watcher receives update in Updates() channel
  5. Loop renders new HTML
  6. HTML sent via SSE to browser
  7. Datastar receives SSE event
  8. Datastar updates #todos-container DOM

Result: Todo checkbox updates immediately!

Bonus: If you have multiple browser tabs open, they ALL have watchers, so they ALL receive the update!


Step 4: Complex Interaction - Inline Editing

User clicks todo text to edit it

4.1: Click to Start Editing

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 labelGET /api/todos/0/edit

4.2: StartEdit Handler

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

4.3: StartEditing Logic

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.

4.4: Template Conditional Rendering

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:

  1. Input field replaces label
  2. data-bind-input: Two-way binds input value to $input signal
  3. data-on-keydown: On Enter key:
    • Check if input is not empty
    • Send PUT /api/todos/0/edit with current $input value
    • Clear input ($input = '')
  4. 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')"
/>

4.5: User Types and Presses Enter

Datastar automatically:

  1. Binds input value to $input signal
  2. On Enter key pressed
  3. Makes PUT /api/todos/0/edit
  4. Automatically includes current signals as form data!

Request payload:

POST /api/todos/0/edit
Content-Type: application/x-www-form-urlencoded

input=Buy+groceries

4.6: SaveEdit Handler

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.

4.7: EditTodo Logic

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


Step 5: View Filtering

User clicks "Active" to show only incomplete todos

5.1: Filter Buttons

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

5.2: SetMode Handler

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

5.3: SetMode Logic

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.

5.4: Conditional Rendering

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 todos
  • TodoViewModeActive (1): Show only if !todo.Completed
  • TodoViewModeCompleted (2): Show only if todo.Completed

Result: Only matching todos rendered in HTML. Server-side filtering!


Key Concepts Demonstrated by Todo

1. Single Page Application Feel, Zero JavaScript

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

2. Real-Time Sync Without WebSockets

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!

3. Progressive Enhancement

The app works even without JavaScript:

  1. Disable JavaScript in browser
  2. Visit page → Full HTML loads
  3. 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.

4. Server-Side Rendering of Everything

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

5. Type-Safe Templates

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:

  • todo must be *Todo type
  • i must be int
  • 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!


Building Todo from Scratch

Now let's build the Todo feature from the ground up. We'll progress through these stages:

  1. Basic routing and static pages (traditional Go web)
  2. Adding interactivity with Datastar (the hypermedia layer)
  3. Adding state persistence with NATS (the database layer)
  4. Adding real-time sync with SSE (the real-time layer)

This progression helps you understand each layer independently before combining them.

Prerequisites

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.


Stage 1: Basic Routing and Static Pages

Goal: Render a static todo list using Go handlers and Templ templates.

1.1: Create Feature Structure

mkdir -p features/todos/pages
mkdir -p features/todos/components

1.2: Define Data Models

features/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 item
  • TodoMVC: Aggregate root - complete app state
  • ViewMode: Type-safe enum instead of strings

1.3: Create Basic Page Template

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

1.4: Create Todo List Component

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 parameters
  • for i, todo := range mvc.Todos: Regular Go loops
  • if len(mvc.Todos) == 0: Regular Go conditionals
  • { todo.Text }: Expression interpolation

1.5: Generate Template Code

go tool templ generate

This creates *_templ.go files with compiled template functions.

1.6: Create Handlers

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.ResponseWriter and *http.Request
  • Create/fetch data
  • Render template
  • Handle errors

1.7: Setup Routes

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
}

1.8: Register Feature in Main Router

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
}

1.9: Test Stage 1

go tool task live

Visit 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

Stage 2: Adding Interactivity with Datastar

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)

2.1: Update Page Template with Datastar

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:

  1. When this div renders in browser
  2. Datastar automatically makes GET request to /todos/data
  3. Response HTML replaces contents of #todos-container

2.2: Make TodoList Component Interactive

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

2.3: Update Handlers for Commands

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:

  1. Parse request (URL params, form data, signals)
  2. Validate input
  3. Modify state (in-memory for now)
  4. Render updated HTML
  5. 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

2.4: Update Routes

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
}

2.5: Test Stage 2

go tool task live

Visit http://localhost:8080/todos

Try:

  • Add a new todo (type and press Enter)
  • Toggle todos as complete
  • Delete todos

What happens:

  1. Page loads → data-on-load triggers
  2. GET /todos/data → Server sends HTML
  3. Datastar swaps HTML into #todos-container
  4. User clicks checkbox → Datastar sends POST
  5. Server updates data, sends new HTML
  6. 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

Stage 3: Adding Persistence with NATS

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)

3.1: Create Todo Service

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

3.2: Add Session Management

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
}

3.3: Update Handlers to Use Service

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:

  1. Get user ID from session
  2. Load current state from NATS
  3. Modify state
  4. Save back to NATS
  5. Render and send HTML

3.4: Update Routes to Inject Dependencies

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
}

3.5: Update Main Router

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
}

3.6: Test Stage 3

go tool task live

Visit 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> --raw

What 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

Stage 4: Adding Real-Time Sync with SSE

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

4.1: Add Watcher to Service

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
}

4.2: Update TodosData Handler for Real-Time

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:

  1. Initial load: Get todos from NATS
  2. Setup watcher: Subscribe to changes on this user's key
  3. Event loop: Run forever, listening for:
    • ctx.Done(): Client closed tab → exit gracefully
    • watcher.Updates(): NATS detected change → send new HTML
  4. 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
}

4.3: Don't Forget Import

Add to imports in features/todos/handlers.go:

import (
    // ... existing imports ...
    "encoding/json"
)

4.4: Test Stage 4

go tool task live

Testing real-time sync:

  1. Open http://localhost:8080/todos in two browser tabs
  2. In Tab 1: Add a todo
  3. Watch Tab 2: Todo appears automatically!
  4. In Tab 2: Toggle a todo
  5. 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:

  1. Tab 1 sends POST request
  2. Handler saves to NATS: kv.Put(userID, data)
  3. NATS detects key userID changed
  4. NATS sends notification to all watchers of this key
  5. Tab 1's watcher receives notification → sends HTML to Tab 1
  6. Tab 2's watcher receives notification → sends HTML to Tab 2
  7. 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!

Understanding the Complete System

Now that you've built it, let's understand how all the layers work together.

The CQRS Pattern

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

The SSE Connection Lifecycle

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

The NATS Architecture

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

Data Flow Diagram

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!

Next Steps

You've now built a complete real-time todo application! Here's what to explore next:

Add More Features

  1. Inline editing

    • Click todo text to edit
    • Use data-on-click__outside to cancel
    • See actual implementation in features/index/components/todo.templ:157-173
  2. View filtering (All/Active/Completed)

    • Add mode buttons
    • Filter in template conditionally
    • See features/index/components/todo.templ:113-125
  3. Bulk operations

    • Toggle all todos
    • Clear completed
    • See features/index/services/todo_service.go:90-106

Explore Advanced Patterns

  1. Optimistic updates

    • Update UI immediately
    • Revert on server error
  2. Loading indicators

    • Use data-indicator attribute
    • Show spinners during requests
  3. Form validation

    • Server-side validation
    • Return form with errors
  4. Infinite scroll

    • Use data-intersects attribute
    • Load more as user scrolls

Study the Real Implementation

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!

Resources


Congratulations!

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!


Building Your First Feature

If you want to build a simpler "greeting" feature to practice, here's a quick starter:

Step 1: Create Feature Directory

mkdir -p features/greeting/pages

Step 2: Create the Template

features/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:

  1. Package and imports: Templ files start with Go package declaration
  2. Signals struct: Defines reactive state shape (becomes JSON)
  3. GreetingPage: The full page with layout
  4. GreetingContent: The interactive part
  5. data-on-load: Fetches initial data when element loads
  6. data-signals: Initializes reactive state
  7. data-bind-input: Two-way binds input to $name signal
  8. data-text: One-way binds text content to $greeting signal
  9. data-on-click: Triggers action on click

Step 3: Generate Go Code from Template

go tool templ generate

This creates greeting_templ.go with compiled template functions.

Step 4: Create Handlers

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:

  1. GreetingPage:

    • Serves the full HTML page on first load
    • Just renders the template directly
  2. 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
  3. 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)

Step 5: Setup Routes

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 SSE
  • POST /feature/action - Actions that modify state
  • PUT /feature/id - Updates
  • DELETE /feature/id - Deletions

Step 6: Register Feature Routes

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
}

Step 7: Add to Navigation (Optional)

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>

Step 8: Run and Test

go tool task live

Navigate to http://localhost:8080/greeting

What happens:

  1. Browser requests /greeting → Server sends full HTML page
  2. Page loads, data-on-load triggers → Request to /greeting/data
  3. Server sends HTML fragment via SSE → Replaces #greeting-container
  4. Type in input → $name signal updates automatically
  5. Click button → POST to /greeting/generate → Server sends signal update
  6. $greeting updates → data-text auto-updates DOM

Working with State and Persistence

The greeting example used in-memory state. Real apps need persistence. Northstar uses NATS JetStream's Key-Value store.

Understanding NATS KV Storage

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

Creating a Service with Persistence

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(), &note); err != nil {
            continue
        }

        notes = append(notes, &note)
    }

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

  1. Namespacing: Use patterns like user:123:note:456 to organize keys
  2. Serialization: Marshal/unmarshal to JSON
  3. Error handling: Always check errors
  4. Context: Pass context for cancellation
  5. Watching: Use watchers for real-time updates

Using the Service in Handlers

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

Real-time Updates with Watchers

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

Advanced Patterns

Pattern 1: Form Handling

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

Pattern 2: Validation and Error Handling

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...
}

Pattern 3: Loading States

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 $saving signal
  • Automatically set to true during request
  • Set to false when request completes
  • data-attrs-disabled disables button while saving
  • data-show conditionally shows elements

Pattern 4: Optimistic Updates

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:

  1. User clicks checkbox
  2. Signal updates immediately (optimistic)
  3. Request sent to server
  4. If server succeeds: No change needed
  5. If server fails: Handler sends corrected state

Pattern 5: Infinite Scroll

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 $page signal

Pattern 6: Debounced Search

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

Pattern 7: Modal Dialogs

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?"))
}

Development Workflow

Starting Development

# Start dev server with live reload
go tool task live

# Server runs on http://localhost:8080
# Changes to .go or .templ files trigger rebuild

How live reload works:

  1. Air watches for file changes
  2. Recompiles Go code
  3. Restarts server
  4. Sends reload signal to browser
  5. Browser auto-refreshes

Project Commands

# 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 tidy

File Watching Patterns

Files that trigger rebuild:

  • **/*.go - Go source files
  • **/*.templ - Template files
  • go.mod - Dependencies

Files ignored:

  • **/.* - Hidden files
  • **/*_test.go - Test files
  • data/ - NATS storage
  • tmp/ - Build artifacts

Debugging

Using VS Code

  1. Open project in VS Code
  2. Set breakpoint in code
  3. Press F5 or use "Debug Main" configuration
  4. Debugger attaches, execution pauses at breakpoint

Using Delve CLI

# Start debugger
go tool task debug

# In delve prompt:
(dlv) break main.main
(dlv) continue
(dlv) print variableName
(dlv) next
(dlv) quit

Logging

import "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 parsing

Working with NATS

Installing NATS CLI

brew install nats-io/nats-tools/nats

Useful Commands

# 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

Browser DevTools

Watching SSE Connections

  1. Open DevTools (F12)
  2. Go to Network tab
  3. Filter by "EventStream" type
  4. Click on connection to see:
    • Request headers
    • SSE events received
    • Timing information

Inspecting Datastar State

// 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')

Testing

Manual Testing Checklist

  • 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

Testing SSE Endpoints

Use curl to test SSE:

curl -N -H "Accept: text/event-stream" \
  http://localhost:8080/counter/data

Output:

data: <div id="container">...
data: signal {"count": 0}

Common Issues and Solutions

Issue: Template not updating

Cause: Forgot to regenerate after editing .templ file

Solution:

go tool templ generate
# Or just wait for Air to detect change

Issue: Route not found (404)

Checklist:

  1. Is feature registered in router/router.go?
  2. Is route defined in feature's routes.go?
  3. Does URL match route pattern?
  4. Is HTTP method correct (GET/POST/etc)?

Issue: SSE connection keeps closing

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

Issue: Data not persisting

Checklist:

  1. Is NATS server running? (Check logs)
  2. Is bucket created? (nats kv ls)
  3. Are you saving correctly? (Check for errors)
  4. Is TTL too short? (Check bucket config)

Issue: Signals not updating

Causes:

  • Typo in signal name ($count vs $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>

Next Steps

Learning Path

  1. Build the greeting feature (from this tutorial)
  2. Study the counter feature (features/counter/)
    • Simple handlers
    • Session storage
    • Multiple signals
  3. Study the todo feature (features/index/)
    • NATS persistence
    • Complex interactions
    • Form handling
  4. Build your own feature
    • Start simple (no persistence)
    • Add persistence with NATS
    • Add real-time updates

Resources

Go Resources

Datastar Resources

Templ Resources

NATS Resources

Feature Ideas to Build

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)

Contributing to Northstar

  1. Fork the repository
  2. Create a feature branch
  3. Add your feature in features/
  4. Test thoroughly
  5. 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

Conclusion

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:

  1. Server controls UI: Business logic stays on server
  2. HTML over the wire: Send HTML fragments, not JSON
  3. Reactive updates: Datastar handles DOM updates automatically
  4. Real-time built-in: SSE + NATS make real-time easy
  5. 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!


Appendix: Quick Reference

Datastar Attributes Reference

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 Helper Functions

sse := datastar.NewSSE(w, r)

// Send HTML fragment
sse.PatchElementTempl(template)

// Send signal update
sse.MarshalAndPatchSignals(signalsMap)

// Execute JavaScript
sse.ExecuteScript("alert('hello')")

NATS KV Operations

// 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
}

Common Templ Patterns

// 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>
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment