Full Stack Go: Build a Real-Time Task Board with HTMX, Templ and Alpine.js
Build a complete task board in Go with HTMX for reactivity, Templ for type-safe templates, Alpine.js, auth, PostgreSQL, and SSE for live updates.
Full Stack Go: Build a Real-Time Task Board with HTMX, Templ and Alpine.js
Think of a restaurant where the same team manages the dining room, the kitchen, and the supply chain. One language, one mental model, one deployment. That is what full-stack Go feels like: the server that handles your business logic also renders the HTML, manages the database, and pushes live updates to every connected browser — all without a separate frontend project, a Node.js build pipeline, or a JavaScript framework that weighs more than the application itself.
This guide builds a Task Board — a Kanban-style application with authentication, drag-and-drop columns, real-time updates via Server-Sent Events (SSE), and a responsive UI. The entire stack runs on Go, with minimal JavaScript where it genuinely helps the user experience.
The Stack
| Layer | Technology | Why |
|---|---|---|
| HTTP Router | Chi | Composable middleware, stdlib-compatible |
| Templates | Templ | Type-safe, compiled Go templates with LSP support |
| Reactivity | HTMX | Server-driven HTML swaps, no virtual DOM |
| Client State | Alpine.js | Lightweight reactive attributes for dropdowns, modals |
| Styling | Tailwind CSS | Utility-first, works perfectly with Templ components |
| Database | PostgreSQL + pgx | Connection pooling, prepared statements |
| Auth | bcrypt + secure cookies | Session-based, no JWT complexity |
| Live Updates | SSE (Server-Sent Events) | Native browser support, simpler than WebSockets |
| Migrations | golang-migrate | Version-controlled schema changes |
Project Structure
A full-stack Go project needs clear boundaries between business logic, HTTP handling, database access, and templates. This structure separates concerns without over-engineering.
taskboard/
cmd/
server/
main.go # Entry point, wiring
internal/
config/
config.go # Environment configuration
domain/
user.go # User entity
board.go # Board, Column, Task entities
errors.go # Domain errors
auth/
service.go # Registration, login, session management
middleware.go # Auth middleware for Chi
repository.go # User + session persistence
board/
service.go # Board business logic
repository.go # Board persistence
sse.go # Server-Sent Events hub
handler/
auth.go # Login, register, logout routes
board.go # Board CRUD + task operations
sse.go # SSE endpoint
middleware.go # Common HTTP middleware
view/
layout.templ # Base HTML layout
auth.templ # Login and register pages
board.templ # Board, columns, task cards
components.templ # Reusable UI components
error.templ # Error pages
migrations/
001_users.up.sql
001_users.down.sql
002_boards.up.sql
002_boards.down.sql
static/
css/output.css # Compiled Tailwind
js/htmx.min.js # HTMX (17KB gzipped)
js/alpine.min.js # Alpine.js (15KB gzipped)
tailwind.config.js
Makefile
Dockerfile
docker-compose.yml
The internal/ directory uses Go’s visibility rules to prevent importing these packages from outside the module. Each subdirectory groups by capability, not by layer: auth contains everything related to authentication, board contains everything related to boards.
Domain Layer
The domain layer defines what a task board is, independent of HTTP, databases, or templates. Every entity enforces its own invariants.
Entities
// internal/domain/user.go
package domain
import (
"time"
"github.com/google/uuid"
)
type User struct {
ID uuid.UUID
Email string
PasswordHash string
DisplayName string
CreatedAt time.Time
}
type Session struct {
ID string
UserID uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
}
func (s Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
The User entity holds the password hash, never the plaintext password. The Session entity is a simple token with an expiration. No JWT decoding, no token refresh — just a row in the database that gets deleted on logout.
// internal/domain/board.go
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type Board struct {
ID uuid.UUID
OwnerID uuid.UUID
Name string
Columns []Column
CreatedAt time.Time
UpdatedAt time.Time
}
type Column struct {
ID uuid.UUID
BoardID uuid.UUID
Name string
Position int
WIPLimit int
Tasks []Task
}
type Task struct {
ID uuid.UUID
ColumnID uuid.UUID
Title string
Description string
Priority Priority
Position int
AssignedTo *uuid.UUID
CreatedBy uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
type Priority int
const (
PriorityLow Priority = 0
PriorityMedium Priority = 1
PriorityHigh Priority = 2
PriorityUrgent Priority = 3
)
func (p Priority) String() string {
switch p {
case PriorityLow:
return "low"
case PriorityMedium:
return "medium"
case PriorityHigh:
return "high"
case PriorityUrgent:
return "urgent"
default:
return "unknown"
}
}
func (p Priority) Color() string {
switch p {
case PriorityLow:
return "bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200"
case PriorityMedium:
return "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
case PriorityHigh:
return "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-200"
case PriorityUrgent:
return "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200"
default:
return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
}
}
Notice the Color() method on Priority. In a full-stack Go application, the domain knows how to describe itself visually because the same binary renders the UI. This is a pragmatic choice: the priority badge color is a direct function of the priority level, and putting it in the domain avoids scattering color logic across templates.
Validation
The Column entity enforces WIP (Work In Progress) limits. When a task moves into a column that has reached its limit, the operation fails with a domain error.
// internal/domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrWIPLimitExceeded = errors.New("column WIP limit exceeded")
ErrInvalidInput = errors.New("invalid input")
ErrDuplicateEmail = errors.New("email already registered")
ErrInvalidPosition = errors.New("invalid position")
)
// Add to board.go
func (c Column) CanAcceptTask() error {
if c.WIPLimit > 0 && len(c.Tasks) >= c.WIPLimit {
return ErrWIPLimitExceeded
}
return nil
}
func (b *Board) MoveTask(taskID, targetColumnID uuid.UUID, position int) error {
var targetCol *Column
for i := range b.Columns {
if b.Columns[i].ID == targetColumnID {
targetCol = &b.Columns[i]
break
}
}
if targetCol == nil {
return ErrNotFound
}
// Check WIP limit (exclude the task itself if it's already in this column)
taskAlreadyInColumn := false
for _, t := range targetCol.Tasks {
if t.ID == taskID {
taskAlreadyInColumn = true
break
}
}
if !taskAlreadyInColumn {
if err := targetCol.CanAcceptTask(); err != nil {
return err
}
}
if position < 0 {
return ErrInvalidPosition
}
return nil
}
Database Layer
Migrations
Two migration files set up the schema. Users and sessions live in the first migration; boards, columns, and tasks in the second. This separation means you can add boards to a system that already has authentication.
-- migrations/001_users.up.sql
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
-- migrations/001_users.down.sql
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
-- migrations/002_boards.up.sql
CREATE TABLE boards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE columns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
board_id UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
wip_limit INTEGER NOT NULL DEFAULT 0,
UNIQUE(board_id, position)
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
column_id UUID NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_boards_owner ON boards(owner_id);
CREATE INDEX idx_columns_board ON columns(board_id);
CREATE INDEX idx_tasks_column ON tasks(column_id);
CREATE INDEX idx_tasks_assigned ON tasks(assigned_to);
-- migrations/002_boards.down.sql
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS columns;
DROP TABLE IF EXISTS boards;
The UNIQUE(board_id, position) constraint on columns prevents two columns from occupying the same position within a board. The ON DELETE CASCADE clauses ensure that deleting a board removes its columns and tasks automatically.
Repository
The board repository handles all SQL operations. Each method maps directly to a use case.
// internal/board/repository.go
package board
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"taskboard/internal/domain"
)
type Repository struct {
pool *pgxpool.Pool
}
func NewRepository(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
func (r *Repository) CreateBoard(ctx context.Context, board *domain.Board) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`INSERT INTO boards (id, owner_id, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)`,
board.ID, board.OwnerID, board.Name, board.CreatedAt, board.UpdatedAt,
)
if err != nil {
return fmt.Errorf("insert board: %w", err)
}
// Create default columns
defaults := []struct {
name string
position int
wip int
}{
{"To Do", 0, 0},
{"In Progress", 1, 3},
{"Review", 2, 2},
{"Done", 3, 0},
}
for _, col := range defaults {
colID := uuid.New()
_, err = tx.Exec(ctx,
`INSERT INTO columns (id, board_id, name, position, wip_limit)
VALUES ($1, $2, $3, $4, $5)`,
colID, board.ID, col.name, col.position, col.wip,
)
if err != nil {
return fmt.Errorf("insert column %s: %w", col.name, err)
}
}
return tx.Commit(ctx)
}
func (r *Repository) GetBoardWithTasks(ctx context.Context, boardID, ownerID uuid.UUID) (*domain.Board, error) {
var board domain.Board
err := r.pool.QueryRow(ctx,
`SELECT id, owner_id, name, created_at, updated_at
FROM boards WHERE id = $1 AND owner_id = $2`,
boardID, ownerID,
).Scan(&board.ID, &board.OwnerID, &board.Name, &board.CreatedAt, &board.UpdatedAt)
if err != nil {
if err == pgx.ErrNoRows {
return nil, domain.ErrNotFound
}
return nil, fmt.Errorf("query board: %w", err)
}
rows, err := r.pool.Query(ctx,
`SELECT c.id, c.board_id, c.name, c.position, c.wip_limit,
t.id, t.title, t.description, t.priority, t.position,
t.assigned_to, t.created_by, t.created_at, t.updated_at
FROM columns c
LEFT JOIN tasks t ON t.column_id = c.id
WHERE c.board_id = $1
ORDER BY c.position, t.position`,
boardID,
)
if err != nil {
return nil, fmt.Errorf("query columns: %w", err)
}
defer rows.Close()
columnMap := make(map[uuid.UUID]*domain.Column)
var columnOrder []uuid.UUID
for rows.Next() {
var col domain.Column
var taskID, taskTitle, taskDesc *string
var taskPriority, taskPosition *int
var taskAssigned, taskCreatedBy *uuid.UUID
var taskCreated, taskUpdated *time.Time
err := rows.Scan(
&col.ID, &col.BoardID, &col.Name, &col.Position, &col.WIPLimit,
&taskID, &taskTitle, &taskDesc, &taskPriority, &taskPosition,
&taskAssigned, &taskCreatedBy, &taskCreated, &taskUpdated,
)
if err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
if _, exists := columnMap[col.ID]; !exists {
col.Tasks = []domain.Task{}
columnMap[col.ID] = &col
columnOrder = append(columnOrder, col.ID)
}
if taskID != nil {
task := domain.Task{
ID: uuid.MustParse(*taskID),
ColumnID: col.ID,
Title: *taskTitle,
Description: *taskDesc,
Priority: domain.Priority(*taskPriority),
Position: *taskPosition,
AssignedTo: taskAssigned,
CreatedBy: *taskCreatedBy,
CreatedAt: *taskCreated,
UpdatedAt: *taskUpdated,
}
columnMap[col.ID].Tasks = append(columnMap[col.ID].Tasks, task)
}
}
for _, colID := range columnOrder {
board.Columns = append(board.Columns, *columnMap[colID])
}
return &board, nil
}
func (r *Repository) MoveTask(ctx context.Context, taskID, targetColumnID uuid.UUID, position int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback(ctx)
// Shift existing tasks down to make room
_, err = tx.Exec(ctx,
`UPDATE tasks SET position = position + 1
WHERE column_id = $1 AND position >= $2`,
targetColumnID, position,
)
if err != nil {
return fmt.Errorf("shift tasks: %w", err)
}
// Move the task
_, err = tx.Exec(ctx,
`UPDATE tasks SET column_id = $1, position = $2, updated_at = now()
WHERE id = $3`,
targetColumnID, position, taskID,
)
if err != nil {
return fmt.Errorf("move task: %w", err)
}
return tx.Commit(ctx)
}
Notice the LEFT JOIN in GetBoardWithTasks. A single query fetches the board, all columns, and all tasks. The application code assembles the nested structure from the flat rows. This avoids the N+1 query problem that plagues naive implementations.
Adding the missing import:
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"taskboard/internal/domain"
)
Authentication
Authentication is the foundation of a multi-user application. This implementation uses bcrypt for password hashing and secure, HTTP-only cookies for session management. No JWTs, no refresh tokens, no client-side token storage.
Auth Service
// internal/auth/service.go
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"taskboard/internal/domain"
)
const (
sessionDuration = 7 * 24 * time.Hour // 1 week
bcryptCost = 12
)
type Service struct {
repo *Repository
}
func NewService(repo *Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Register(ctx context.Context, email, password, displayName string) (*domain.User, error) {
if len(password) < 8 {
return nil, fmt.Errorf("%w: password must be at least 8 characters", domain.ErrInvalidInput)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
user := &domain.User{
ID: uuid.New(),
Email: email,
PasswordHash: string(hash),
DisplayName: displayName,
CreatedAt: time.Now(),
}
if err := s.repo.CreateUser(ctx, user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) Login(ctx context.Context, email, password string) (*domain.Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, domain.ErrUnauthorized
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, domain.ErrUnauthorized
}
token, err := generateSessionToken()
if err != nil {
return nil, fmt.Errorf("generate token: %w", err)
}
session := &domain.Session{
ID: token,
UserID: user.ID,
ExpiresAt: time.Now().Add(sessionDuration),
CreatedAt: time.Now(),
}
if err := s.repo.CreateSession(ctx, session); err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
return session, nil
}
func (s *Service) ValidateSession(ctx context.Context, token string) (*domain.User, error) {
session, err := s.repo.GetSession(ctx, token)
if err != nil {
return nil, domain.ErrUnauthorized
}
if session.IsExpired() {
_ = s.repo.DeleteSession(ctx, token)
return nil, domain.ErrUnauthorized
}
user, err := s.repo.GetUserByID(ctx, session.UserID)
if err != nil {
return nil, domain.ErrUnauthorized
}
return user, nil
}
func (s *Service) Logout(ctx context.Context, token string) error {
return s.repo.DeleteSession(ctx, token)
}
func generateSessionToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
The session token is 32 bytes of crypto/rand encoded as hex — 64 characters of cryptographic randomness. This is resistant to brute-force attacks. The Login method returns the same ErrUnauthorized whether the email does not exist or the password is wrong. This prevents email enumeration attacks.
Auth Middleware
The middleware extracts the session cookie, validates it, and injects the user into the request context. Protected routes use this middleware to ensure only authenticated users can access them.
// internal/auth/middleware.go
package auth
import (
"context"
"net/http"
)
type contextKey string
const userContextKey contextKey = "user"
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
user, err := s.ValidateSession(r.Context(), cookie.Value)
if err != nil {
// Clear invalid cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func UserFromContext(ctx context.Context) *domain.User {
user, _ := ctx.Value(userContextKey).(*domain.User)
return user
}
Three security properties matter here: HttpOnly prevents JavaScript from reading the cookie (XSS protection), Secure ensures the cookie only travels over HTTPS, and SameSite: Lax prevents CSRF attacks on state-changing requests.
Adding the missing import:
import (
"context"
"net/http"
"taskboard/internal/domain"
)
Templ: Type-Safe Templates
Templ is a Go template engine that compiles .templ files into Go code. Unlike html/template, Templ provides compile-time type checking, auto-escaping, and IDE support with autocompletion. Every template is a Go function that takes typed parameters and returns rendered HTML.
Layout
The base layout wraps every page. It includes the HTMX and Alpine.js scripts, Tailwind CSS, and a dark mode toggle.
// internal/view/layout.templ
package view
templ Layout(title string, authenticated bool) {
<!DOCTYPE html>
<html lang="en" class="h-full" x-data="{ dark: localStorage.getItem('dark') === 'true' }"
x-init="$watch('dark', val => { localStorage.setItem('dark', val); document.documentElement.classList.toggle('dark', val) }); document.documentElement.classList.toggle('dark', dark)"
:class="{ 'dark': dark }">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title } - TaskBoard</title>
<meta name="description" content="A real-time task board built with Go, HTMX, and Templ"/>
<meta name="robots" content="noindex"/>
<link rel="stylesheet" href="/static/css/output.css"/>
<script src="/static/js/htmx.min.js" defer></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="h-full bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors">
<nav class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<a href="/" class="text-xl font-bold text-blue-600 dark:text-blue-400">TaskBoard</a>
<div class="flex items-center gap-4">
<button @click="dark = !dark"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<span x-show="!dark">Moon</span>
<span x-show="dark">Sun</span>
</button>
if authenticated {
<a href="/boards" class="text-sm hover:text-blue-600 dark:hover:text-blue-400">My Boards</a>
<form hx-post="/logout" hx-target="body">
<button type="submit" class="text-sm text-red-600 dark:text-red-400 hover:underline">
Logout
</button>
</form>
}
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto p-4">
{ children... }
</main>
</body>
</html>
}
Alpine.js handles exactly two things: dark mode persistence and small interactive elements like dropdowns. The x-data directive on <html> initializes the dark mode state from localStorage. The x-init watcher syncs changes back. This is the kind of client-side behavior that does not justify a React application.
Auth Pages
The login and register pages use HTMX to submit forms without a full page reload. When the server responds with a redirect header, HTMX follows it automatically.
// internal/view/auth.templ
package view
templ LoginPage(errorMsg string) {
@Layout("Login", false) {
<div class="max-w-md mx-auto mt-16">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-8">
<h1 class="text-2xl font-bold mb-6 text-center">Sign In</h1>
if errorMsg != "" {
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg text-sm">
{ errorMsg }
</div>
}
<form hx-post="/login" hx-target="closest div" hx-swap="outerHTML" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1">Password</label>
<input type="password" id="password" name="password" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
</div>
<button type="submit"
class="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Sign In
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
No account?
<a href="/register" class="text-blue-600 dark:text-blue-400 hover:underline">Create one</a>
</p>
</div>
</div>
}
}
templ RegisterPage(errorMsg string) {
@Layout("Register", false) {
<div class="max-w-md mx-auto mt-16">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-8">
<h1 class="text-2xl font-bold mb-6 text-center">Create Account</h1>
if errorMsg != "" {
<div class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded-lg text-sm">
{ errorMsg }
</div>
}
<form hx-post="/register" hx-target="closest div" hx-swap="outerHTML" class="space-y-4">
<div>
<label for="display_name" class="block text-sm font-medium mb-1">Display Name</label>
<input type="text" id="display_name" name="display_name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
</div>
<div>
<label for="email" class="block text-sm font-medium mb-1">Email</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1">Password</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters</p>
</div>
<button type="submit"
class="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors">
Create Account
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
Already have an account?
<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline">Sign in</a>
</p>
</div>
</div>
}
}
The hx-post attribute tells HTMX to send the form via AJAX. The hx-target="closest div" and hx-swap="outerHTML" mean that if the server returns an error, it replaces the form container with the error message included. On success, the server sends a HX-Redirect header and HTMX navigates to the boards page.
Board View
This is the core of the application. Each column renders as a vertical list. Tasks are cards that can be dragged between columns using HTMX’s hx-trigger with the Sortable.js extension, or moved with button clicks for accessibility.
// internal/view/board.templ
package view
import (
"fmt"
"taskboard/internal/domain"
)
templ BoardPage(board *domain.Board, user *domain.User) {
@Layout(board.Name, true) {
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">{ board.Name }</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{ fmt.Sprintf("%d columns, %d tasks", len(board.Columns), countTasks(board)) }
</p>
</div>
<button @click="$refs.newTaskModal.showModal()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
New Task
</button>
</div>
<div id="board-columns"
class="flex gap-4 overflow-x-auto pb-4"
hx-ext="sse"
sse-connect={ fmt.Sprintf("/boards/%s/events", board.ID) }
sse-swap="board-update"
hx-target="#board-columns"
hx-swap="innerHTML">
for _, col := range board.Columns {
@ColumnView(col, board.ID)
}
</div>
@NewTaskModal(board)
}
}
templ ColumnView(col domain.Column, boardID uuid.UUID) {
<div class="flex-shrink-0 w-72 bg-gray-100 dark:bg-gray-800 rounded-xl p-3">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold text-sm uppercase tracking-wide text-gray-600 dark:text-gray-300">
{ col.Name }
</h2>
<div class="flex items-center gap-2">
<span class="text-xs bg-gray-200 dark:bg-gray-700 px-2 py-0.5 rounded-full">
{ fmt.Sprintf("%d", len(col.Tasks)) }
</span>
if col.WIPLimit > 0 {
<span class={ wipBadgeClass(col) }>
{ fmt.Sprintf("max %d", col.WIPLimit) }
</span>
}
</div>
</div>
<div class="space-y-2 min-h-[2rem]"
id={ fmt.Sprintf("col-%s", col.ID) }
hx-post={ fmt.Sprintf("/boards/%s/tasks/move", boardID) }
hx-trigger="drop"
hx-target="#board-columns"
hx-swap="innerHTML"
hx-vals={ fmt.Sprintf(`{"target_column_id": "%s"}`, col.ID) }>
for _, task := range col.Tasks {
@TaskCard(task, boardID)
}
</div>
</div>
}
templ TaskCard(task domain.Task, boardID uuid.UUID) {
<div class="bg-white dark:bg-gray-700 rounded-lg p-3 shadow-sm border border-gray-200
dark:border-gray-600 cursor-pointer hover:shadow-md transition-shadow"
draggable="true"
id={ fmt.Sprintf("task-%s", task.ID) }
x-data="{ expanded: false }">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium leading-tight">{ task.Title }</h3>
<span class={ "text-xs px-2 py-0.5 rounded-full whitespace-nowrap " + task.Priority.Color() }>
{ task.Priority.String() }
</span>
</div>
if task.Description != "" {
<button @click="expanded = !expanded"
class="mt-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-show="!expanded">Show details</span>
<span x-show="expanded">Hide details</span>
</button>
<p x-show="expanded" x-transition
class="mt-1 text-xs text-gray-500 dark:text-gray-400 leading-relaxed">
{ task.Description }
</p>
}
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-gray-400">
{ task.CreatedAt.Format("Jan 2") }
</span>
<button hx-delete={ fmt.Sprintf("/boards/%s/tasks/%s", boardID, task.ID) }
hx-target="#board-columns"
hx-swap="innerHTML"
hx-confirm="Delete this task?"
class="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
Delete
</button>
</div>
</div>
}
templ NewTaskModal(board *domain.Board) {
<dialog x-ref="newTaskModal"
class="rounded-xl shadow-xl p-0 w-full max-w-md backdrop:bg-black/50
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<form hx-post={ fmt.Sprintf("/boards/%s/tasks", board.ID) }
hx-target="#board-columns"
hx-swap="innerHTML"
@htmx:after-request="$refs.newTaskModal.close(); this.reset()"
class="p-6 space-y-4">
<h2 class="text-lg font-bold">New Task</h2>
<div>
<label for="title" class="block text-sm font-medium mb-1">Title</label>
<input type="text" id="title" name="title" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none"/>
</div>
<div>
<label for="description" class="block text-sm font-medium mb-1">Description</label>
<textarea id="description" name="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="column_id" class="block text-sm font-medium mb-1">Column</label>
<select id="column_id" name="column_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none">
for _, col := range board.Columns {
<option value={ col.ID.String() }>{ col.Name }</option>
}
</select>
</div>
<div>
<label for="priority" class="block text-sm font-medium mb-1">Priority</label>
<select id="priority" name="priority"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 outline-none">
<option value="0">Low</option>
<option value="1" selected>Medium</option>
<option value="2">High</option>
<option value="3">Urgent</option>
</select>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="$refs.newTaskModal.close()"
class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
Create Task
</button>
</div>
</form>
</dialog>
}
func countTasks(board *domain.Board) int {
count := 0
for _, col := range board.Columns {
count += len(col.Tasks)
}
return count
}
func wipBadgeClass(col domain.Column) string {
if col.WIPLimit > 0 && len(col.Tasks) >= col.WIPLimit {
return "text-xs bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-2 py-0.5 rounded-full"
}
return "text-xs bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded-full"
}
This is where the full-stack advantage becomes clear. The TaskCard template calls task.Priority.Color() directly — a domain method that returns Tailwind classes. The wipBadgeClass helper checks the column’s WIP limit and returns a red badge when the limit is reached. No API serialization, no JSON parsing, no client-side state management.
The HTMX attributes deserve attention:
hx-ext="sse"+sse-connect: Connects to the SSE endpoint for this board. When the server pushes aboard-updateevent, HTMX replaces the entire columns container with fresh HTML.hx-poston column divs: When a task is dropped into a column, HTMX sends the move request with the target column ID.hx-confirm: Native browser confirmation dialog before deleting a task.@htmx:after-request: Alpine.js closes the modal and resets the form after HTMX completes the request.
Server-Sent Events: Real-Time Updates
SSE is the simplest way to push updates from server to browser. Unlike WebSockets, SSE uses a standard HTTP connection, works through proxies, and reconnects automatically. For a task board where updates flow in one direction (server to all browsers), SSE is the right tool.
SSE Hub
The hub manages connected clients. When any user changes the board, the hub broadcasts the updated HTML to every connected client.
// internal/board/sse.go
package board
import (
"fmt"
"sync"
"github.com/google/uuid"
)
type SSEHub struct {
mu sync.RWMutex
clients map[uuid.UUID]map[chan string]struct{} // boardID -> set of channels
}
func NewSSEHub() *SSEHub {
return &SSEHub{
clients: make(map[uuid.UUID]map[chan string]struct{}),
}
}
func (h *SSEHub) Subscribe(boardID uuid.UUID) chan string {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan string, 10) // buffered to prevent blocking
if h.clients[boardID] == nil {
h.clients[boardID] = make(map[chan string]struct{})
}
h.clients[boardID][ch] = struct{}{}
return ch
}
func (h *SSEHub) Unsubscribe(boardID uuid.UUID, ch chan string) {
h.mu.Lock()
defer h.mu.Unlock()
if board, ok := h.clients[boardID]; ok {
delete(board, ch)
close(ch)
if len(board) == 0 {
delete(h.clients, boardID)
}
}
}
func (h *SSEHub) Broadcast(boardID uuid.UUID, html string) {
h.mu.RLock()
defer h.mu.RUnlock()
if board, ok := h.clients[boardID]; ok {
for ch := range board {
select {
case ch <- html:
default:
// Client is too slow, skip this update
}
}
}
}
func FormatSSE(event, data string) string {
return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)
}
The Broadcast method uses a non-blocking send. If a client’s channel is full (the client is slow), the update is dropped. This prevents one slow client from blocking all others. The 10-slot buffer provides enough headroom for normal usage.
SSE Handler
// internal/handler/sse.go
package handler
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"taskboard/internal/auth"
"taskboard/internal/board"
)
type SSEHandler struct {
hub *board.SSEHub
boardRepo *board.Repository
}
func NewSSEHandler(hub *board.SSEHub, repo *board.Repository) *SSEHandler {
return &SSEHandler{hub: hub, boardRepo: repo}
}
func (h *SSEHandler) Stream(w http.ResponseWriter, r *http.Request) {
boardID, err := uuid.Parse(chi.URLParam(r, "boardID"))
if err != nil {
http.Error(w, "invalid board ID", http.StatusBadRequest)
return
}
user := auth.UserFromContext(r.Context())
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
ch := h.hub.Subscribe(boardID)
defer h.hub.Unsubscribe(boardID, ch)
// Send initial keepalive
fmt.Fprintf(w, ": keepalive\n\n")
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case html, ok := <-ch:
if !ok {
return
}
fmt.Fprint(w, board.FormatSSE("board-update", html))
flusher.Flush()
}
}
}
The X-Accel-Buffering: no header tells nginx (if you are running behind it) not to buffer the response. Without this, updates would accumulate in the proxy buffer and arrive in batches instead of immediately.
HTTP Handlers
The handlers connect HTTP requests to the domain logic and render the appropriate Templ responses. Every mutation handler broadcasts the updated board to all connected SSE clients.
// internal/handler/auth.go
package handler
import (
"net/http"
"time"
"taskboard/internal/auth"
"taskboard/internal/view"
)
type AuthHandler struct {
service *auth.Service
}
func NewAuthHandler(service *auth.Service) *AuthHandler {
return &AuthHandler{service: service}
}
func (h *AuthHandler) ShowLogin(w http.ResponseWriter, r *http.Request) {
view.LoginPage("").Render(r.Context(), w)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
view.LoginPage("Invalid form data").Render(r.Context(), w)
return
}
session, err := h.service.Login(r.Context(), r.FormValue("email"), r.FormValue("password"))
if err != nil {
view.LoginPage("Invalid email or password").Render(r.Context(), w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: session.ID,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("HX-Redirect", "/boards")
w.WriteHeader(http.StatusOK)
}
func (h *AuthHandler) ShowRegister(w http.ResponseWriter, r *http.Request) {
view.RegisterPage("").Render(r.Context(), w)
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
view.RegisterPage("Invalid form data").Render(r.Context(), w)
return
}
_, err := h.service.Register(
r.Context(),
r.FormValue("email"),
r.FormValue("password"),
r.FormValue("display_name"),
)
if err != nil {
view.RegisterPage(err.Error()).Render(r.Context(), w)
return
}
// Auto-login after registration
session, err := h.service.Login(r.Context(), r.FormValue("email"), r.FormValue("password"))
if err != nil {
view.LoginPage("Account created. Please sign in.").Render(r.Context(), w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: session.ID,
Path: "/",
Expires: session.ExpiresAt,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("HX-Redirect", "/boards")
w.WriteHeader(http.StatusOK)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err == nil {
h.service.Logout(r.Context(), cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("HX-Redirect", "/login")
w.WriteHeader(http.StatusOK)
}
The HX-Redirect header is the key pattern for HTMX form submissions. Instead of returning HTML, the server tells HTMX to navigate to a different page. This works for both successful logins and post-registration flows.
// internal/handler/board.go
package handler
import (
"bytes"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"taskboard/internal/auth"
"taskboard/internal/board"
"taskboard/internal/domain"
"taskboard/internal/view"
)
type BoardHandler struct {
repo *board.Repository
hub *board.SSEHub
}
func NewBoardHandler(repo *board.Repository, hub *board.SSEHub) *BoardHandler {
return &BoardHandler{repo: repo, hub: hub}
}
func (h *BoardHandler) ShowBoard(w http.ResponseWriter, r *http.Request) {
boardID, err := uuid.Parse(chi.URLParam(r, "boardID"))
if err != nil {
http.Error(w, "invalid board ID", http.StatusBadRequest)
return
}
user := auth.UserFromContext(r.Context())
b, err := h.repo.GetBoardWithTasks(r.Context(), boardID, user.ID)
if err != nil {
http.Error(w, "board not found", http.StatusNotFound)
return
}
view.BoardPage(b, user).Render(r.Context(), w)
}
func (h *BoardHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
boardID, _ := uuid.Parse(chi.URLParam(r, "boardID"))
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
columnID, err := uuid.Parse(r.FormValue("column_id"))
if err != nil {
http.Error(w, "invalid column", http.StatusBadRequest)
return
}
priority, _ := strconv.Atoi(r.FormValue("priority"))
task := &domain.Task{
ID: uuid.New(),
ColumnID: columnID,
Title: r.FormValue("title"),
Description: r.FormValue("description"),
Priority: domain.Priority(priority),
Position: 0,
CreatedBy: user.ID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.repo.CreateTask(r.Context(), task); err != nil {
http.Error(w, "failed to create task", http.StatusInternalServerError)
return
}
h.broadcastBoardUpdate(r, boardID, user.ID)
}
func (h *BoardHandler) MoveTask(w http.ResponseWriter, r *http.Request) {
boardID, _ := uuid.Parse(chi.URLParam(r, "boardID"))
user := auth.UserFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
taskID, err := uuid.Parse(r.FormValue("task_id"))
if err != nil {
http.Error(w, "invalid task", http.StatusBadRequest)
return
}
targetColumnID, err := uuid.Parse(r.FormValue("target_column_id"))
if err != nil {
http.Error(w, "invalid column", http.StatusBadRequest)
return
}
position, _ := strconv.Atoi(r.FormValue("position"))
if err := h.repo.MoveTask(r.Context(), taskID, targetColumnID, position); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.broadcastBoardUpdate(r, boardID, user.ID)
}
func (h *BoardHandler) DeleteTask(w http.ResponseWriter, r *http.Request) {
boardID, _ := uuid.Parse(chi.URLParam(r, "boardID"))
taskID, _ := uuid.Parse(chi.URLParam(r, "taskID"))
user := auth.UserFromContext(r.Context())
if err := h.repo.DeleteTask(r.Context(), taskID); err != nil {
http.Error(w, "failed to delete task", http.StatusInternalServerError)
return
}
h.broadcastBoardUpdate(r, boardID, user.ID)
}
func (h *BoardHandler) broadcastBoardUpdate(r *http.Request, boardID, userID uuid.UUID) {
b, err := h.repo.GetBoardWithTasks(r.Context(), boardID, userID)
if err != nil {
return
}
var buf bytes.Buffer
for _, col := range b.Columns {
view.ColumnView(col, boardID).Render(r.Context(), &buf)
}
// Send to all SSE clients
h.hub.Broadcast(boardID, buf.String())
// Also send to the requesting client via HTTP response
w := r.Context().Value("http.ResponseWriter").(http.ResponseWriter)
buf2 := bytes.Buffer{}
for _, col := range b.Columns {
view.ColumnView(col, boardID).Render(r.Context(), &buf2)
}
w.Header().Set("Content-Type", "text/html")
w.Write(buf2.Bytes())
}
The broadcastBoardUpdate method does two things: it sends the updated board HTML to all SSE-connected clients, and it returns the same HTML as the HTTP response to the client that made the change. This means the user who created the task sees the update immediately (from the HTTP response), and all other users see it moments later (from the SSE event).
Routing and Server Setup
Configuration
// internal/config/config.go
package config
import (
"fmt"
"time"
"github.com/caarlos0/env/v11"
)
type Config struct {
Port int `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
SessionKey string `env:"SESSION_KEY,required"`
ReadTimeout time.Duration `env:"READ_TIMEOUT" envDefault:"5s"`
WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"`
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" envDefault:"120s"`
Environment string `env:"ENVIRONMENT" envDefault:"development"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
func (c *Config) IsDev() bool {
return c.Environment == "development"
}
func (c *Config) Addr() string {
return fmt.Sprintf(":%d", c.Port)
}
Router
// cmd/server/main.go
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"taskboard/internal/auth"
"taskboard/internal/board"
"taskboard/internal/config"
"taskboard/internal/handler"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
// Database
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Ping(context.Background()); err != nil {
slog.Error("failed to ping database", "error", err)
os.Exit(1)
}
slog.Info("connected to database")
// Dependencies
authRepo := auth.NewRepository(pool)
authService := auth.NewService(authRepo)
boardRepo := board.NewRepository(pool)
sseHub := board.NewSSEHub()
authHandler := handler.NewAuthHandler(authService)
boardHandler := handler.NewBoardHandler(boardRepo, sseHub)
sseHandler := handler.NewSSEHandler(sseHub, boardRepo)
// Router
r := chi.NewRouter()
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Compress(5))
r.Use(middleware.Timeout(30 * time.Second))
// Security headers
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
})
// Static files
fileServer := http.FileServer(http.Dir("static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// Public routes
r.Get("/login", authHandler.ShowLogin)
r.Post("/login", authHandler.Login)
r.Get("/register", authHandler.ShowRegister)
r.Post("/register", authHandler.Register)
// Protected routes
r.Group(func(r chi.Router) {
r.Use(authService.Middleware)
r.Post("/logout", authHandler.Logout)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/boards", http.StatusSeeOther)
})
// Boards
r.Get("/boards", boardHandler.ListBoards)
r.Post("/boards", boardHandler.CreateBoard)
r.Get("/boards/{boardID}", boardHandler.ShowBoard)
r.Delete("/boards/{boardID}", boardHandler.DeleteBoard)
// Tasks
r.Post("/boards/{boardID}/tasks", boardHandler.CreateTask)
r.Post("/boards/{boardID}/tasks/move", boardHandler.MoveTask)
r.Delete("/boards/{boardID}/tasks/{taskID}", boardHandler.DeleteTask)
// SSE
r.Get("/boards/{boardID}/events", sseHandler.Stream)
})
// Server
srv := &http.Server{
Addr: cfg.Addr(),
Handler: r,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}
// Graceful shutdown
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
go func() {
slog.Info("server starting", "addr", cfg.Addr(), "env", cfg.Environment)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
os.Exit(1)
}
}()
<-done
slog.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown error", "error", err)
}
slog.Info("server stopped")
}
The security headers middleware adds five layers of browser protection:
X-Content-Type-Options: nosniffprevents browsers from interpreting files as a different MIME type.X-Frame-Options: DENYprevents the page from being embedded in iframes (clickjacking protection).X-XSS-Protectionenables the browser’s built-in XSS filter.Referrer-Policylimits the information sent in the Referer header.Strict-Transport-Securityforces HTTPS for all future requests (only set when already on HTTPS).
The graceful shutdown pattern ensures that in-flight requests complete before the server stops. The SSE connections will close when the server’s context is cancelled, and the clients will see the stream end.
Docker and Deployment
Multi-Stage Dockerfile
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Install templ
RUN go install github.com/a-h/templ/cmd/templ@latest
# Dependencies
COPY go.mod go.sum ./
RUN go mod download
# Generate templ files
COPY . .
RUN templ generate
# Build binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
# Runtime stage
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /server .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/static ./static
EXPOSE 8080
ENTRYPOINT ["./server"]
The templ generate step compiles all .templ files into Go source files before the binary build. The final image contains only the binary, migrations, and static assets — no Go toolchain, no source code.
Docker Compose
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: "postgres://taskboard:taskboard@db:5432/taskboard?sslmode=disable"
SESSION_KEY: "change-this-to-a-random-64-char-string-in-production"
ENVIRONMENT: "development"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: taskboard
POSTGRES_PASSWORD: taskboard
POSTGRES_DB: taskboard
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskboard"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:
Makefile
.PHONY: dev build test migrate-up migrate-down docker-up docker-down templ-generate tailwind
# Development
dev:
@echo "Starting development server..."
@air
build: templ-generate tailwind
go build -o bin/server ./cmd/server
test:
go test ./... -v -race -count=1
# Templates and CSS
templ-generate:
templ generate
tailwind:
npx tailwindcss -i static/css/input.css -o static/css/output.css --minify
tailwind-watch:
npx tailwindcss -i static/css/input.css -o static/css/output.css --watch
# Database
migrate-up:
migrate -path migrations -database "$(DATABASE_URL)" up
migrate-down:
migrate -path migrations -database "$(DATABASE_URL)" down 1
# Docker
docker-up:
docker compose up -d --build
docker-down:
docker compose down
# Full development setup
setup:
go install github.com/a-h/templ/cmd/templ@latest
go install github.com/air-verse/air@latest
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
npm install
cp .env.example .env
Testing
Domain Tests
// internal/domain/board_test.go
package domain
import (
"testing"
"github.com/google/uuid"
)
func TestColumn_CanAcceptTask(t *testing.T) {
tests := []struct {
name string
wipLimit int
tasks int
wantErr error
}{
{
name: "no limit accepts any number of tasks",
wipLimit: 0,
tasks: 100,
wantErr: nil,
},
{
name: "under limit accepts task",
wipLimit: 3,
tasks: 2,
wantErr: nil,
},
{
name: "at limit rejects task",
wipLimit: 3,
tasks: 3,
wantErr: ErrWIPLimitExceeded,
},
{
name: "over limit rejects task",
wipLimit: 3,
tasks: 5,
wantErr: ErrWIPLimitExceeded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
col := Column{
ID: uuid.New(),
WIPLimit: tt.wipLimit,
Tasks: make([]Task, tt.tasks),
}
err := col.CanAcceptTask()
if err != tt.wantErr {
t.Errorf("CanAcceptTask() = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestPriority_String(t *testing.T) {
tests := []struct {
priority Priority
want string
}{
{PriorityLow, "low"},
{PriorityMedium, "medium"},
{PriorityHigh, "high"},
{PriorityUrgent, "urgent"},
{Priority(99), "unknown"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := tt.priority.String(); got != tt.want {
t.Errorf("Priority.String() = %q, want %q", got, tt.want)
}
})
}
}
func TestSession_IsExpired(t *testing.T) {
now := time.Now()
tests := []struct {
name string
expiresAt time.Time
want bool
}{
{"future expiry is not expired", now.Add(time.Hour), false},
{"past expiry is expired", now.Add(-time.Hour), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Session{ExpiresAt: tt.expiresAt}
if got := s.IsExpired(); got != tt.want {
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
}
})
}
}
Adding the missing import:
import (
"testing"
"time"
"github.com/google/uuid"
)
Integration Test for Auth
// internal/auth/service_test.go
package auth
import (
"context"
"testing"
"taskboard/internal/domain"
)
func TestService_Register_PasswordTooShort(t *testing.T) {
svc := NewService(nil) // repo not called for validation errors
_, err := svc.Register(context.Background(), "test@example.com", "short", "Test")
if err == nil {
t.Fatal("expected error for short password")
}
}
func TestService_Login_WrongPassword(t *testing.T) {
// This test requires a real or mocked repository
// Shown here as a pattern for integration tests with testcontainers
// repo := setupTestDB(t)
// svc := NewService(repo)
//
// _, err := svc.Register(ctx, "test@example.com", "correctpassword", "Test")
// require.NoError(t, err)
//
// _, err = svc.Login(ctx, "test@example.com", "wrongpassword")
// assert.ErrorIs(t, err, domain.ErrUnauthorized)
}
How HTMX Replaces a JavaScript Framework
The traditional SPA approach to building this task board would require:
- A React/Vue/Svelte project with its own build pipeline
- A state management library (Redux, Zustand, Pinia)
- An API client with error handling and retry logic
- Type definitions that duplicate your backend models
- A bundler configuration (Vite, webpack)
- Client-side routing
- JSON serialization and deserialization at every boundary
With HTMX, the server returns HTML fragments. The browser does not need to know the data model. It does not need to transform JSON into DOM nodes. The server decides what the user sees, and HTMX swaps it into the page.
Here is the mental model:
Traditional SPA:
Browser <-- JSON --> API Server <-- SQL --> Database
(Client renders HTML from JSON)
HTMX + Go:
Browser <-- HTML --> Go Server <-- SQL --> Database
(Server renders HTML directly)
The Go server has direct access to the database, the domain logic, and the template engine. There is no serialization boundary between the data and its presentation. When a task’s priority changes, the server renders the new badge color immediately — it does not send {"priority": 2} and hope the client knows what color that maps to.
When to Use Alpine.js
Alpine.js handles purely client-side interactions that do not need a server round-trip:
- Dark mode toggle: Reading from and writing to
localStorage - Expanding/collapsing task details: Show/hide a description
- Modal management: Opening and closing the “New Task” dialog
- Dropdown menus: Priority selectors, user menus
These are UI state changes that have no business logic behind them. They do not change data, they do not need validation, and they do not need to persist. Alpine.js adds these behaviors with HTML attributes, without writing JavaScript files.
SEO Considerations for Server-Rendered Go Apps
Server-rendered applications have a natural SEO advantage: the HTML that arrives at the browser is the same HTML that search engines index. There is no hydration delay, no JavaScript-dependent content, no empty <div id="app"> that crawlers see before your framework boots.
For a Go web application, implement these practices:
// Semantic HTML with proper meta tags
templ SEOHead(title, description, canonical string) {
<title>{ title }</title>
<meta name="description" content={ description }/>
<link rel="canonical" href={ canonical }/>
<meta property="og:title" content={ title }/>
<meta property="og:description" content={ description }/>
<meta property="og:type" content="website"/>
<meta property="og:url" content={ canonical }/>
}
Key points:
- Every page has a unique title and description. The board page title includes the board name. The login page says “Sign In”.
- Canonical URLs prevent duplicate content. If the same board is accessible at
/boards/123and/b/123, the canonical tag points to the primary URL. - HTML is semantic. Use
<nav>,<main>,<article>,<dialog>— not<div>for everything. - The page loads fast. A Go binary serves HTML in microseconds. HTMX is 17KB gzipped. Alpine.js is 15KB. The total JavaScript payload is smaller than most framework bundle chunks.
Performance Profile
A full-stack Go application has a performance profile that is fundamentally different from a JavaScript stack:
| Metric | Go + HTMX | React SPA + Node API |
|---|---|---|
| Time to First Byte | ~2ms (Go serves HTML) | ~50ms (Node serves JSON, then client renders) |
| First Contentful Paint | ~100ms (HTML is the content) | ~500ms (download JS, parse, execute, render) |
| JavaScript payload | 32KB (HTMX + Alpine) | 200-500KB (React + state + router) |
| Memory per connection | ~8KB (goroutine) | ~2MB (Node event loop overhead) |
| Concurrent users | 100K+ (goroutines are cheap) | ~10K (event loop saturation) |
| Deployment | Single binary + static files | Node runtime + package manager + build tools |
These are not theoretical numbers. A Go HTTP server with Chi serves a Templ-rendered page in under 5 milliseconds, including the database query. The binary is 15MB. The Docker image is 25MB. The entire application — server, templates, static files — deploys as one artifact.
Development Workflow
Hot Reload with Air
Air watches for file changes and restarts the server automatically. Combined with Templ’s file watcher and Tailwind’s watch mode, you get a development experience comparable to any modern framework.
# .air.toml
root = "."
tmp_dir = "tmp"
[build]
cmd = "templ generate && go build -o tmp/server ./cmd/server"
bin = "tmp/server"
include_ext = ["go", "templ", "sql"]
exclude_dir = ["tmp", "node_modules", "static"]
delay = 1000
[misc]
clean_on_exit = true
Run three terminals during development:
# Terminal 1: Go server with hot reload
make dev
# Terminal 2: Tailwind CSS watcher
make tailwind-watch
# Terminal 3: Templ file watcher (optional, Air handles this too)
templ generate --watch
The Full-Stack Go Philosophy
Building a web application entirely in Go is not about avoiding JavaScript at all costs. It is about choosing the right tool for each layer:
- Business logic belongs on the server, where it can be tested, secured, and deployed without client cooperation.
- Data rendering belongs on the server, where it has direct access to the database and domain types.
- User interactions that do not need data (toggles, modals, animations) belong on the client, handled by a lightweight library like Alpine.js.
- Dynamic updates that need fresh data use HTMX to request HTML fragments from the server.
The result is an application that is fast because there is no serialization boundary. It is secure because the client never sees raw data — only rendered HTML. It is maintainable because one team writes one language and deploys one binary. And it is accessible because the initial page load is pure HTML that works without JavaScript.
The best architecture is not the one with the most layers. It is the one where each layer does exactly what it is good at. Go renders HTML faster than your browser can parse JavaScript. HTMX turns that speed into interactivity. Alpine.js adds the polish. Together, they build applications that feel instant — because they are.
Tags
Related Articles
Organizational Health Through Architecture: Building Alignment, Trust & Healthy Culture
Learn how architecture decisions shape organizational culture, health, and alignment. Discover how to use architecture as a tool for building trust, preventing silos, enabling transparency, and creating sustainable organizational growth.
Team Health & Burnout Prevention: How Architecture Decisions Impact Human Well-being
Master the human side of architecture. Learn to recognize burnout signals, architect sustainable systems, build psychological safety, and protect team health. Because healthy teams build better systems.
Difficult Conversations & Conflict Resolution: Navigating Disagreement, Politics & Defensive Teams
Master the art of having difficult conversations as an architect. Learn how to manage technical disagreements, handle defensive teams, say no effectively, and navigate organizational politics without damaging relationships.