Full Stack con Go: Tablero de Tareas con HTMX, Templ y Alpine.js
Backend

Full Stack con Go: Tablero de Tareas con HTMX, Templ y Alpine.js

Construye un tablero Kanban completo en Go con HTMX, Templ, Alpine.js, autenticación segura, PostgreSQL y actualizaciones en tiempo real con SSE.

Por Omar Flores
#golang #go #full-stack #architecture #postgresql #performance #security #best-practices #guide

Full Stack con Go: Tablero de Tareas con HTMX, Templ y Alpine.js

Imagina un restaurante donde el mismo equipo gestiona el comedor, la cocina y el almacén. Un idioma, un modelo mental, un despliegue. Así se siente el full stack con Go: el servidor que maneja tu lógica de negocio también renderiza el HTML, gestiona la base de datos y envía actualizaciones en vivo a cada navegador conectado — todo sin un proyecto frontend separado, sin pipeline de Node.js, ni un framework de JavaScript que pese más que la propia aplicación.

Esta guía construye un Tablero de Tareas — una aplicación estilo Kanban con autenticación, columnas con arrastrar y soltar, actualizaciones en tiempo real via Server-Sent Events (SSE), y una interfaz responsiva. Todo el stack corre en Go, con JavaScript mínimo donde genuinamente mejora la experiencia del usuario.


El Stack

CapaTecnologíaPor qué
Router HTTPChiMiddleware composable, compatible con stdlib
TemplatesTemplTemplates Go compilados con verificación de tipos y soporte LSP
ReactividadHTMXIntercambio de HTML dirigido por servidor, sin Virtual DOM
Estado clienteAlpine.jsAtributos reactivos ligeros para dropdowns y modales
EstilosTailwind CSSUtility-first, funciona perfectamente con componentes Templ
Base de datosPostgreSQL + pgxPool de conexiones, prepared statements
Authbcrypt + cookies segurasBasada en sesiones, sin complejidad de JWT
ActualizacionesSSE (Server-Sent Events)Soporte nativo del navegador, más simple que WebSockets
Migracionesgolang-migrateCambios de esquema versionados

Estructura del Proyecto

Un proyecto Go full stack necesita límites claros entre la lógica de negocio, el manejo HTTP, el acceso a la base de datos y los templates. Esta estructura separa responsabilidades sin sobreingeniería.

taskboard/
  cmd/
    server/
      main.go              # Punto de entrada, cableado
  internal/
    config/
      config.go            # Configuración por variables de entorno
    domain/
      user.go              # Entidad Usuario
      board.go             # Entidades Tablero, Columna, Tarea
      errors.go            # Errores de dominio
    auth/
      service.go           # Registro, login, gestión de sesiones
      middleware.go        # Middleware de auth para Chi
      repository.go        # Persistencia de usuarios y sesiones
    board/
      service.go           # Lógica de negocio del tablero
      repository.go        # Persistencia del tablero
      sse.go               # Hub de Server-Sent Events
    handler/
      auth.go              # Rutas de login, registro, logout
      board.go             # CRUD de tablero y operaciones de tareas
      sse.go               # Endpoint SSE
      middleware.go        # Middleware HTTP común
    view/
      layout.templ         # Layout HTML base
      auth.templ           # Páginas de login y registro
      board.templ          # Tablero, columnas, tarjetas de tareas
      components.templ     # Componentes UI reutilizables
      error.templ          # Páginas de error
  migrations/
    001_users.up.sql
    001_users.down.sql
    002_boards.up.sql
    002_boards.down.sql
  static/
    css/output.css         # Tailwind compilado
    js/htmx.min.js         # HTMX (17KB comprimido)
    js/alpine.min.js       # Alpine.js (15KB comprimido)
  tailwind.config.js
  Makefile
  Dockerfile
  docker-compose.yml

El directorio internal/ usa las reglas de visibilidad de Go para evitar importaciones desde fuera del módulo. Cada subdirectorio agrupa por capacidad, no por capa: auth contiene todo lo relacionado con autenticación, board contiene todo lo relacionado con tableros.


Capa de Dominio

La capa de dominio define qué es un tablero de tareas, independientemente de HTTP, bases de datos o templates. Cada entidad hace cumplir sus propias invariantes.

Entidades

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

La entidad User guarda el hash de la contraseña, nunca el texto plano. La entidad Session es un token simple con expiración. Sin decodificación JWT, sin token refresh — solo una fila en la base de datos que se elimina al cerrar sesión.

// 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 "baja"
	case PriorityMedium:
		return "media"
	case PriorityHigh:
		return "alta"
	case PriorityUrgent:
		return "urgente"
	default:
		return "desconocida"
	}
}

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

Nota el método Color() en Priority. En una aplicación Go full stack, el dominio sabe cómo describirse visualmente porque el mismo binario renderiza la interfaz. Esta es una decisión pragmática: el color de la etiqueta de prioridad es una función directa del nivel de prioridad, y colocarlo en el dominio evita dispersar la lógica de colores por los templates.

Validación

La entidad Column hace cumplir los límites WIP (Work In Progress). Cuando una tarea se mueve a una columna que ha alcanzado su límite, la operación falla con un error de dominio.

// internal/domain/errors.go
package domain

import "errors"

var (
	ErrNotFound         = errors.New("no encontrado")
	ErrUnauthorized     = errors.New("no autorizado")
	ErrWIPLimitExceeded = errors.New("límite WIP de columna excedido")
	ErrInvalidInput     = errors.New("entrada inválida")
	ErrDuplicateEmail   = errors.New("correo ya registrado")
	ErrInvalidPosition  = errors.New("posición inválida")
)
// Agregar a 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
	}

	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
}

Capa de Base de Datos

Migraciones

Dos archivos de migración configuran el esquema. Los usuarios y sesiones viven en la primera migración; tableros, columnas y tareas en la segunda. Esta separación significa que puedes agregar tableros a un sistema que ya tiene autenticación.

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

La restricción UNIQUE(board_id, position) en columnas evita que dos columnas ocupen la misma posición dentro de un tablero. Las cláusulas ON DELETE CASCADE garantizan que eliminar un tablero elimine sus columnas y tareas automáticamente.

Repositorio

El repositorio del tablero maneja todas las operaciones SQL. Cada método mapea directamente a un caso de uso.

// internal/board/repository.go
package board

import (
	"context"
	"fmt"
	"time"

	"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("iniciar 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("insertar tablero: %w", err)
	}

	// Crear columnas por defecto
	defaults := []struct {
		name     string
		position int
		wip      int
	}{
		{"Por Hacer", 0, 0},
		{"En Progreso", 1, 3},
		{"Revisión", 2, 2},
		{"Completado", 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("insertar columna %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("consultar tablero: %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("consultar columnas: %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("escanear fila: %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("iniciar tx: %w", err)
	}
	defer tx.Rollback(ctx)

	// Desplazar tareas existentes para hacer espacio
	_, 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("desplazar tareas: %w", err)
	}

	// Mover la tarea
	_, 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("mover tarea: %w", err)
	}

	return tx.Commit(ctx)
}

func (r *Repository) CreateTask(ctx context.Context, task *domain.Task) error {
	_, err := r.pool.Exec(ctx,
		`INSERT INTO tasks (id, column_id, title, description, priority, position, created_by, created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
		task.ID, task.ColumnID, task.Title, task.Description,
		task.Priority, task.Position, task.CreatedBy, task.CreatedAt, task.UpdatedAt,
	)
	return err
}

func (r *Repository) DeleteTask(ctx context.Context, taskID uuid.UUID) error {
	_, err := r.pool.Exec(ctx, `DELETE FROM tasks WHERE id = $1`, taskID)
	return err
}

Nota el LEFT JOIN en GetBoardWithTasks. Una sola consulta obtiene el tablero, todas las columnas y todas las tareas. El código de la aplicación ensambla la estructura anidada a partir de las filas planas. Esto evita el problema N+1 que afecta a las implementaciones ingenuas.


Autenticación

La autenticación es la base de una aplicación multi-usuario. Esta implementación usa bcrypt para el hash de contraseñas y cookies seguras HTTP-only para la gestión de sesiones. Sin JWTs, sin tokens de refresco, sin almacenamiento de tokens en el cliente.

Servicio de Auth

// 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 semana
	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: la contraseña debe tener al menos 8 caracteres", domain.ErrInvalidInput)
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
	if err != nil {
		return nil, fmt.Errorf("hash contraseña: %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("generar 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("crear sesión: %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
}

El token de sesión son 32 bytes de crypto/rand codificados como hex — 64 caracteres de aleatoriedad criptográfica. El método Login devuelve el mismo ErrUnauthorized tanto si el correo no existe como si la contraseña es incorrecta. Esto previene ataques de enumeración de correos.

Middleware de Auth

El middleware extrae la cookie de sesión, la valida e inyecta el usuario en el contexto de la petición. Las rutas protegidas usan este middleware para garantizar que solo usuarios autenticados puedan acceder.

// internal/auth/middleware.go
package auth

import (
	"context"
	"net/http"

	"taskboard/internal/domain"
)

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 {
			// Limpiar cookie inválida
			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
}

Tres propiedades de seguridad importan aquí: HttpOnly evita que JavaScript lea la cookie (protección XSS), Secure garantiza que la cookie solo viaje por HTTPS, y SameSite: Lax previene ataques CSRF en peticiones que cambian estado.


Templ: Templates Type-Safe

Templ es un motor de templates Go que compila archivos .templ a código Go. A diferencia de html/template, Templ proporciona verificación de tipos en tiempo de compilación, auto-escape y soporte de IDE con autocompletado. Cada template es una función Go que toma parámetros tipados y retorna HTML renderizado.

Layout Base

El layout base envuelve cada página. Incluye los scripts de HTMX y Alpine.js, Tailwind CSS, y un toggle de modo oscuro.

// internal/view/layout.templ
package view

templ Layout(title string, authenticated bool) {
	<!DOCTYPE html>
	<html lang="es" 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="Tablero de tareas en tiempo real construido con Go, HTMX y 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 text-sm">
						<span x-show="!dark">Modo oscuro</span>
						<span x-show="dark">Modo claro</span>
					</button>
					if authenticated {
						<a href="/boards" class="text-sm hover:text-blue-600 dark:hover:text-blue-400">
							Mis Tableros
						</a>
						<form hx-post="/logout" hx-target="body">
							<button type="submit"
							        class="text-sm text-red-600 dark:text-red-400 hover:underline">
								Cerrar sesión
							</button>
						</form>
					}
				</div>
			</div>
		</nav>
		<main class="max-w-7xl mx-auto p-4">
			{ children... }
		</main>
	</body>
	</html>
}

Alpine.js maneja exactamente dos cosas: la persistencia del modo oscuro y pequeños elementos interactivos como dropdowns. La directiva x-data en <html> inicializa el estado del modo oscuro desde localStorage. El watcher x-init sincroniza los cambios de vuelta. Este es el tipo de comportamiento del lado del cliente que no justifica una aplicación React.

Páginas de Auth

Las páginas de login y registro usan HTMX para enviar formularios sin recargar la página completa. Cuando el servidor responde con una cabecera de redirección, HTMX la sigue automáticamente.

// internal/view/auth.templ
package view

templ LoginPage(errorMsg string) {
	@Layout("Iniciar Sesión", 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">Iniciar Sesión</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">Correo electrónico</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">Contraseña</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">
						Ingresar
					</button>
				</form>
				<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
					¿No tienes cuenta?
					<a href="/register" class="text-blue-600 dark:text-blue-400 hover:underline">
						Crear una
					</a>
				</p>
			</div>
		</div>
	}
}

templ RegisterPage(errorMsg string) {
	@Layout("Crear Cuenta", 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">Crear Cuenta</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">Nombre</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">Correo electrónico</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">Contraseña</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">Mínimo 8 caracteres</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">
						Crear Cuenta
					</button>
				</form>
				<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
					¿Ya tienes cuenta?
					<a href="/login" class="text-blue-600 dark:text-blue-400 hover:underline">
						Inicia sesión
					</a>
				</p>
			</div>
		</div>
	}
}

El atributo hx-post le dice a HTMX que envíe el formulario via AJAX. hx-target="closest div" y hx-swap="outerHTML" significan que si el servidor retorna un error, reemplaza el contenedor del formulario con el mensaje de error incluido. En caso de éxito, el servidor envía una cabecera HX-Redirect y HTMX navega a la página de tableros.

Vista del Tablero

Este es el núcleo de la aplicación. Cada columna se renderiza como una lista vertical. Las tareas son tarjetas que se pueden arrastrar entre columnas.

// internal/view/board.templ
package view

import (
	"fmt"
	"taskboard/internal/domain"
	"github.com/google/uuid"
)

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 columnas, %d tareas", 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">
				Nueva Tarea
			</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="group 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">Ver detalles</span>
				<span x-show="expanded">Ocultar detalles</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("2 Jan") }
			</span>
			<button hx-delete={ fmt.Sprintf("/boards/%s/tasks/%s", boardID, task.ID) }
			        hx-target="#board-columns"
			        hx-swap="innerHTML"
			        hx-confirm="¿Eliminar esta tarea?"
			        class="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity">
				Eliminar
			</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">Nueva Tarea</h2>
			<div>
				<label for="title" class="block text-sm font-medium mb-1">Título</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">Descripción</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">Columna</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">Prioridad</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">Baja</option>
						<option value="1" selected>Media</option>
						<option value="2">Alta</option>
						<option value="3">Urgente</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">
					Cancelar
				</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">
					Crear Tarea
				</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"
}

Server-Sent Events: Actualizaciones en Tiempo Real

SSE es la forma más simple de empujar actualizaciones del servidor al navegador. A diferencia de WebSockets, SSE usa una conexión HTTP estándar, funciona a través de proxies, y se reconecta automáticamente. Para un tablero de tareas donde las actualizaciones fluyen en una dirección (servidor a todos los navegadores), SSE es la herramienta correcta.

Hub SSE

El hub gestiona los clientes conectados. Cuando cualquier usuario cambia el tablero, el hub transmite el HTML actualizado a cada cliente conectado.

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

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 para no bloquear
	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:
				// Cliente demasiado lento, omitir esta actualización
			}
		}
	}
}

func FormatSSE(event, data string) string {
	return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data)
}

El método Broadcast usa un envío no bloqueante. Si el canal de un cliente está lleno (el cliente es lento), la actualización se descarta. Esto evita que un cliente lento bloquee a todos los demás. El buffer de 10 slots proporciona suficiente espacio para el uso normal.

Handler SSE

// 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, "ID de tablero inválido", http.StatusBadRequest)
		return
	}

	user := auth.UserFromContext(r.Context())
	if user == nil {
		http.Error(w, "no autorizado", http.StatusUnauthorized)
		return
	}

	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming no soportado", 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") // Desactivar buffering de nginx

	ch := h.hub.Subscribe(boardID)
	defer h.hub.Unsubscribe(boardID, ch)

	// Enviar keepalive inicial
	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()
		}
	}
}

La cabecera X-Accel-Buffering: no le dice a nginx (si estás corriendo detrás de él) que no almacene en buffer la respuesta. Sin esto, las actualizaciones se acumularían en el buffer del proxy y llegarían en lotes en lugar de inmediatamente.


Handlers HTTP

Los handlers conectan las peticiones HTTP con la lógica de dominio y renderizan las respuestas Templ apropiadas. Cada handler de mutación transmite el tablero actualizado a todos los clientes SSE conectados.

// internal/handler/auth.go
package handler

import (
	"net/http"

	"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("Datos de formulario inválidos").Render(r.Context(), w)
		return
	}

	session, err := h.service.Login(r.Context(), r.FormValue("email"), r.FormValue("password"))
	if err != nil {
		view.LoginPage("Correo o contraseña incorrectos").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("Datos de formulario inválidos").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 después del registro
	session, err := h.service.Login(r.Context(), r.FormValue("email"), r.FormValue("password"))
	if err != nil {
		view.LoginPage("Cuenta creada. Por favor inicia sesión.").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)
}
// 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, "ID de tablero inválido", http.StatusBadRequest)
		return
	}

	user := auth.UserFromContext(r.Context())
	b, err := h.repo.GetBoardWithTasks(r.Context(), boardID, user.ID)
	if err != nil {
		http.Error(w, "Tablero no encontrado", 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, "formulario inválido", http.StatusBadRequest)
		return
	}

	columnID, err := uuid.Parse(r.FormValue("column_id"))
	if err != nil {
		http.Error(w, "columna inválida", 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, "error al crear tarea", http.StatusInternalServerError)
		return
	}

	h.broadcastAndRespond(w, 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, "formulario inválido", http.StatusBadRequest)
		return
	}

	taskID, err := uuid.Parse(r.FormValue("task_id"))
	if err != nil {
		http.Error(w, "tarea inválida", http.StatusBadRequest)
		return
	}

	targetColumnID, err := uuid.Parse(r.FormValue("target_column_id"))
	if err != nil {
		http.Error(w, "columna inválida", 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.broadcastAndRespond(w, 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, "error al eliminar tarea", http.StatusInternalServerError)
		return
	}

	h.broadcastAndRespond(w, r, boardID, user.ID)
}

// broadcastAndRespond renderiza el tablero actualizado, lo transmite via SSE
// y lo devuelve como respuesta HTTP al cliente que hizo la petición.
func (h *BoardHandler) broadcastAndRespond(w http.ResponseWriter, r *http.Request, boardID, userID uuid.UUID) {
	b, err := h.repo.GetBoardWithTasks(r.Context(), boardID, userID)
	if err != nil {
		http.Error(w, "error al cargar tablero", http.StatusInternalServerError)
		return
	}

	// Renderizar HTML de columnas
	var sseBuf bytes.Buffer
	for _, col := range b.Columns {
		view.ColumnView(col, boardID).Render(r.Context(), &sseBuf)
	}
	html := sseBuf.String()

	// Transmitir a todos los clientes SSE
	h.hub.Broadcast(boardID, html)

	// Responder al cliente que hizo la petición
	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(html))
}

Enrutamiento y Configuración del Servidor

Configuración

// 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("parsear 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 Principal

// 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("error al cargar configuración", "error", err)
		os.Exit(1)
	}

	// Base de datos
	pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
	if err != nil {
		slog.Error("error al conectar con la base de datos", "error", err)
		os.Exit(1)
	}
	defer pool.Close()

	if err := pool.Ping(context.Background()); err != nil {
		slog.Error("error al hacer ping a la base de datos", "error", err)
		os.Exit(1)
	}
	slog.Info("conectado a la base de datos")

	// Dependencias
	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()

	// Middleware global
	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))

	// Cabeceras de seguridad
	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)
		})
	})

	// Archivos estáticos
	fileServer := http.FileServer(http.Dir("static"))
	r.Handle("/static/*", http.StripPrefix("/static/", fileServer))

	// Rutas públicas
	r.Get("/login", authHandler.ShowLogin)
	r.Post("/login", authHandler.Login)
	r.Get("/register", authHandler.ShowRegister)
	r.Post("/register", authHandler.Register)

	// Rutas protegidas
	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)
		})

		// Tableros
		r.Get("/boards", boardHandler.ListBoards)
		r.Post("/boards", boardHandler.CreateBoard)
		r.Get("/boards/{boardID}", boardHandler.ShowBoard)
		r.Delete("/boards/{boardID}", boardHandler.DeleteBoard)

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

	// Servidor con apagado elegante
	srv := &http.Server{
		Addr:         cfg.Addr(),
		Handler:      r,
		ReadTimeout:  cfg.ReadTimeout,
		WriteTimeout: cfg.WriteTimeout,
		IdleTimeout:  cfg.IdleTimeout,
	}

	done := make(chan os.Signal, 1)
	signal.Notify(done, os.Interrupt, syscall.SIGTERM)

	go func() {
		slog.Info("servidor iniciando", "addr", cfg.Addr(), "env", cfg.Environment)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("error del servidor", "error", err)
			os.Exit(1)
		}
	}()

	<-done
	slog.Info("apagando servidor")

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		slog.Error("error al apagar", "error", err)
	}

	slog.Info("servidor detenido")
}

Las cabeceras de seguridad del middleware agregan cinco capas de protección del navegador:

  1. X-Content-Type-Options: nosniff evita que los navegadores interpreten archivos como un tipo MIME diferente.
  2. X-Frame-Options: DENY previene que la página sea embebida en iframes (protección contra clickjacking).
  3. X-XSS-Protection activa el filtro XSS integrado del navegador.
  4. Referrer-Policy limita la información enviada en la cabecera Referer.
  5. Strict-Transport-Security fuerza HTTPS para todas las peticiones futuras (solo se establece cuando ya se está en HTTPS).

Docker y Despliegue

Dockerfile Multi-Etapa

# Etapa de construcción
FROM golang:1.25-alpine AS builder

WORKDIR /app

# Instalar templ
RUN go install github.com/a-h/templ/cmd/templ@latest

# Dependencias
COPY go.mod go.sum ./
RUN go mod download

# Generar archivos templ y construir
COPY . .
RUN templ generate
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server

# Etapa de ejecución
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"]

Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: "postgres://taskboard:taskboard@db:5432/taskboard?sslmode=disable"
      SESSION_KEY: "cambia-esto-a-una-cadena-aleatoria-de-64-chars-en-produccion"
      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 tailwind

dev:
	@air

build: templ tailwind
	go build -o bin/server ./cmd/server

test:
	go test ./... -v -race -count=1

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

migrate-up:
	migrate -path migrations -database "$(DATABASE_URL)" up

migrate-down:
	migrate -path migrations -database "$(DATABASE_URL)" down 1

docker-up:
	docker compose up -d --build

docker-down:
	docker compose down

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

Pruebas

Pruebas de Dominio

// internal/domain/board_test.go
package domain

import (
	"testing"
	"time"

	"github.com/google/uuid"
)

func TestColumn_CanAcceptTask(t *testing.T) {
	tests := []struct {
		name     string
		wipLimit int
		tasks    int
		wantErr  error
	}{
		{
			name:     "sin límite acepta cualquier cantidad de tareas",
			wipLimit: 0,
			tasks:    100,
			wantErr:  nil,
		},
		{
			name:     "por debajo del límite acepta tarea",
			wipLimit: 3,
			tasks:    2,
			wantErr:  nil,
		},
		{
			name:     "al límite rechaza tarea",
			wipLimit: 3,
			tasks:    3,
			wantErr:  ErrWIPLimitExceeded,
		},
		{
			name:     "sobre el límite rechaza tarea",
			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, quería %v", err, tt.wantErr)
			}
		})
	}
}

func TestPriority_String(t *testing.T) {
	tests := []struct {
		priority Priority
		want     string
	}{
		{PriorityLow, "baja"},
		{PriorityMedium, "media"},
		{PriorityHigh, "alta"},
		{PriorityUrgent, "urgente"},
		{Priority(99), "desconocida"},
	}

	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, quería %q", got, tt.want)
			}
		})
	}
}

func TestSession_IsExpired(t *testing.T) {
	now := time.Now()

	tests := []struct {
		name      string
		expiresAt time.Time
		want      bool
	}{
		{"expiración futura no está vencida", now.Add(time.Hour), false},
		{"expiración pasada está vencida", 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, quería %v", got, tt.want)
			}
		})
	}
}

Por Qué HTMX Reemplaza un Framework JavaScript

El enfoque SPA tradicional para construir este tablero requeriría:

  • Un proyecto React/Vue/Svelte con su propio pipeline de construcción
  • Una librería de gestión de estado (Redux, Zustand, Pinia)
  • Un cliente de API con manejo de errores y lógica de reintento
  • Definiciones de tipos que duplican tus modelos del backend
  • Una configuración de bundler (Vite, webpack)
  • Enrutamiento del lado del cliente
  • Serialización y deserialización JSON en cada límite

Con HTMX, el servidor devuelve fragmentos HTML. El navegador no necesita conocer el modelo de datos. No necesita transformar JSON en nodos DOM. El servidor decide lo que el usuario ve, y HTMX lo intercambia en la página.

SPA Tradicional:
  Navegador <-- JSON --> API Server <-- SQL --> Base de datos
  (El cliente renderiza HTML desde JSON)

HTMX + Go:
  Navegador <-- HTML --> Go Server <-- SQL --> Base de datos
  (El servidor renderiza HTML directamente)

El servidor Go tiene acceso directo a la base de datos, la lógica de dominio y el motor de templates. No hay un límite de serialización entre los datos y su presentación. Cuando cambia la prioridad de una tarea, el servidor renderiza el nuevo color del badge inmediatamente — no envía {"priority": 2} y espera que el cliente sepa qué color corresponde a eso.

Cuándo Usar Alpine.js

Alpine.js maneja interacciones puramente del lado del cliente que no necesitan un viaje al servidor:

  • Toggle de modo oscuro: Leer y escribir en localStorage
  • Expandir/colapsar detalles de tareas: Mostrar/ocultar una descripción
  • Gestión de modales: Abrir y cerrar el diálogo “Nueva Tarea”
  • Menús dropdown: Selectores de prioridad, menús de usuario

Estos son cambios de estado de UI que no tienen lógica de negocio detrás. No cambian datos, no necesitan validación, y no necesitan persistirse. Alpine.js agrega estos comportamientos con atributos HTML, sin escribir archivos JavaScript.


SEO en Aplicaciones Go Renderizadas en Servidor

Las aplicaciones renderizadas en servidor tienen una ventaja SEO natural: el HTML que llega al navegador es el mismo HTML que indexan los motores de búsqueda. No hay retraso de hidratación, no hay contenido dependiente de JavaScript, no hay un <div id="app"> vacío que los crawlers ven antes de que arranque el framework.

Para una aplicación web Go, implementa estas prácticas:

// HTML semántico con meta tags adecuadas
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 }/>
}

Puntos clave:

  1. Cada página tiene un título y descripción únicos. El título de la página del tablero incluye el nombre del tablero. La página de login dice “Iniciar Sesión”.
  2. Las URLs canónicas previenen contenido duplicado. Si el mismo tablero es accesible en /boards/123 y /b/123, la etiqueta canónica apunta a la URL principal.
  3. El HTML es semántico. Usa <nav>, <main>, <article>, <dialog> — no <div> para todo.
  4. La página carga rápido. Un binario Go sirve HTML en microsegundos. HTMX tiene 17KB comprimido. Alpine.js tiene 15KB. El payload total de JavaScript es menor que la mayoría de los chunks de bundle de frameworks.

Perfil de Rendimiento

Una aplicación Go full stack tiene un perfil de rendimiento fundamentalmente diferente al de un stack JavaScript:

MétricaGo + HTMXReact SPA + Node API
Time to First Byte~2ms (Go sirve HTML)~50ms (Node sirve JSON, luego el cliente renderiza)
First Contentful Paint~100ms (el HTML es el contenido)~500ms (descargar JS, parsear, ejecutar, renderizar)
Payload JavaScript32KB (HTMX + Alpine)200-500KB (React + estado + router)
Memoria por conexión~8KB (goroutine)~2MB (sobrecarga del event loop de Node)
Usuarios concurrentes100K+ (goroutines son económicas)~10K (saturación del event loop)
DespliegueBinario único + archivos estáticosRuntime Node + gestor de paquetes + herramientas de build

Un servidor HTTP Go con Chi sirve una página renderizada con Templ en menos de 5 milisegundos, incluyendo la consulta a la base de datos. El binario tiene 15MB. La imagen Docker tiene 25MB. Toda la aplicación — servidor, templates, archivos estáticos — se despliega como un artefacto único.


Flujo de Desarrollo

Recarga en Caliente con Air

Air observa cambios en archivos y reinicia el servidor automáticamente. Combinado con el watcher de Templ y el modo watch de Tailwind, obtienes una experiencia de desarrollo comparable a cualquier framework moderno.

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

Ejecuta tres terminales durante el desarrollo:

# Terminal 1: Servidor Go con recarga en caliente
make dev

# Terminal 2: Watcher de Tailwind CSS
make tailwind-watch

# Terminal 3: Watcher de Templ (opcional, Air también lo maneja)
templ generate --watch

La Filosofía del Full Stack con Go

Construir una aplicación web completamente en Go no es evitar JavaScript a toda costa. Es elegir la herramienta correcta para cada capa:

  • La lógica de negocio pertenece al servidor, donde puede ser probada, asegurada y desplegada sin cooperación del cliente.
  • El renderizado de datos pertenece al servidor, donde tiene acceso directo a la base de datos y los tipos de dominio.
  • Las interacciones de usuario que no necesitan datos (toggles, modales, animaciones) pertenecen al cliente, manejadas por una librería ligera como Alpine.js.
  • Las actualizaciones dinámicas que necesitan datos frescos usan HTMX para solicitar fragmentos HTML del servidor.

El resultado es una aplicación que es rápida porque no hay límite de serialización. Es segura porque el cliente nunca ve datos crudos — solo HTML renderizado. Es mantenible porque un equipo escribe un lenguaje y despliega un binario. Y es accesible porque la carga inicial de la página es HTML puro que funciona sin JavaScript.

La mejor arquitectura no es la que tiene más capas. Es aquella donde cada capa hace exactamente lo que hace bien. Go renderiza HTML más rápido de lo que tu navegador puede parsear JavaScript. HTMX convierte esa velocidad en interactividad. Alpine.js agrega el pulido. Juntos, construyen aplicaciones que se sienten instantáneas — porque lo son.