Full Stack Go: Notes and Todos App with HTMX, Templ and SQLite

Full Stack Go: Notes and Todos App with HTMX, Templ and SQLite

Build a complete Notes and Todos app in Go: markdown notes with tags, todo checklists, full-text search, auth, HTMX reactivity, Templ templates, and SQLite.

By Omar Flores

Full Stack Go: Notes and Todos App with HTMX, Templ and SQLite

Think of a well-organized notebook. The cover, the pages, the index tabs, and the bookmark are all part of the same object — one thing you carry, one thing you open, one thing you put away. A full-stack Go application has this same quality: one binary carries the router, the templates, the business rules, and the database connection. You open one file to understand the system. You deploy one artifact.

This guide builds Noteflow — a notes and todos application where users can write markdown notes with tags and pinning, manage todo lists with priorities and due dates, search across everything instantly, and do all of it without a page reload in sight. The entire stack is Go: modernc.org/sqlite (no CGO required), Templ for type-safe templates, HTMX for server-driven reactivity, Alpine.js for local UI state, and Tailwind CSS for styling.


Why SQLite for This Project

PostgreSQL is the right choice for multi-server deployments and high write concurrency. SQLite is the right choice for everything else: single-server applications, embedded tools, development environments, and applications where operational simplicity matters more than horizontal scaling.

modernc.org/sqlite is a pure-Go port of SQLite — no C compiler, no CGO, no build dependencies. The database is a single file. Backup is cp noteflow.db noteflow.db.bak. Migrations are plain SQL files. The entire application deploys as one binary plus one database file.

For a notes app that serves one user or a small team, SQLite handles hundreds of writes per second and millions of reads. It is the right tool.


The Stack

LayerTechnologyWhy
RouterChiComposable middleware, stdlib http.Handler
TemplatesTemplCompiled, type-safe, IDE-supported
ReactivityHTMXHTML fragments, no JSON serialization
Client stateAlpine.jsDropdowns, tag inputs, local toggles
StylingTailwind CSSUtility-first, dark mode built-in
DatabaseSQLite + modernc.org/sqliteZero dependencies, single-file DB
Authbcrypt + secure cookiesSession-based, simple and correct
Markdowngithub.com/yuin/goldmarkSafe HTML rendering from user content
SearchSQLite FTS5Full-text search without Elasticsearch

Project Structure

noteflow/
  cmd/
    server/
      main.go                 # Entry point
  internal/
    config/
      config.go
    domain/
      note.go                 # Note, Tag entities
      todo.go                 # Todo, TodoItem entities
      user.go                 # User, Session entities
      errors.go
    auth/
      repository.go
      service.go
      middleware.go
    note/
      repository.go           # Notes CRUD + FTS search
      service.go              # Markdown rendering, tag management
    todo/
      repository.go           # Todos CRUD + items
      service.go
    handler/
      auth.go
      note.go
      todo.go
      search.go
    view/
      layout.templ            # Base layout, nav, dark mode
      auth.templ              # Login, register
      note/
        list.templ            # Notes grid with pinned section
        detail.templ          # Single note view (rendered markdown)
        form.templ            # Create / edit note form
        card.templ            # Note card component (reused in list + search)
      todo/
        list.templ            # Todo lists with progress bars
        detail.templ          # Single list with checklist items
        form.templ            # Create / edit todo list
        item.templ            # Single item with checkbox (HTMX partial)
      search/
        results.templ         # Unified search results
      components/
        tag.templ             # Tag badge, tag input
        empty.templ           # Empty state illustrations
        toast.templ           # Toast notification
  migrations/
    001_users.up.sql
    001_users.down.sql
    002_notes.up.sql
    002_notes.down.sql
    003_todos.up.sql
    003_todos.down.sql
  static/
    css/output.css
    js/htmx.min.js
    js/alpine.min.js
  Makefile
  Dockerfile
  .air.toml
  go.mod

Every subdirectory in internal/view/ mirrors a domain concept. A developer looking for the note list template opens internal/view/note/list.templ — no hunting through a flat templates/ directory.


Domain Layer

The domain describes the problem, not the implementation. Notes have content, tags, and a pinned state. Todos have a list of items, each with its own completion state.

// internal/domain/note.go
package domain

import (
	"time"

	"github.com/google/uuid"
)

type Note struct {
	ID        uuid.UUID
	UserID    uuid.UUID
	Title     string
	Content   string   // Markdown source
	HTML      string   // Rendered, sanitized HTML (not persisted)
	Tags      []Tag
	Pinned    bool
	WordCount int
	CreatedAt time.Time
	UpdatedAt time.Time
}

type Tag struct {
	ID    uuid.UUID
	Name  string
	Color string // Tailwind color class e.g. "blue", "green"
	Count int    // How many notes use this tag (for sidebar display)
}

func (n *Note) HasTag(tagName string) bool {
	for _, t := range n.Tags {
		if t.Name == tagName {
			return true
		}
	}
	return false
}

func (n *Note) Excerpt(chars int) string {
	if len(n.Content) <= chars {
		return n.Content
	}
	return n.Content[:chars] + "..."
}
// internal/domain/todo.go
package domain

import (
	"time"

	"github.com/google/uuid"
)

type TodoList struct {
	ID          uuid.UUID
	UserID      uuid.UUID
	Title       string
	Description string
	Items       []TodoItem
	Priority    ListPriority
	DueDate     *time.Time
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

type TodoItem struct {
	ID          uuid.UUID
	ListID      uuid.UUID
	Text        string
	Done        bool
	Position    int
	CompletedAt *time.Time
	CreatedAt   time.Time
}

type ListPriority int

const (
	ListPriorityNone   ListPriority = 0
	ListPriorityLow    ListPriority = 1
	ListPriorityMedium ListPriority = 2
	ListPriorityHigh   ListPriority = 3
)

func (p ListPriority) Label() string {
	switch p {
	case ListPriorityLow:
		return "Low"
	case ListPriorityMedium:
		return "Medium"
	case ListPriorityHigh:
		return "High"
	default:
		return "None"
	}
}

func (p ListPriority) BadgeClass() string {
	switch p {
	case ListPriorityLow:
		return "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300"
	case ListPriorityMedium:
		return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300"
	case ListPriorityHigh:
		return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300"
	default:
		return "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
	}
}

func (l *TodoList) Progress() int {
	if len(l.Items) == 0 {
		return 0
	}
	done := 0
	for _, item := range l.Items {
		if item.Done {
			done++
		}
	}
	return (done * 100) / len(l.Items)
}

func (l *TodoList) IsOverdue() bool {
	if l.DueDate == nil {
		return false
	}
	return time.Now().After(*l.DueDate) && l.Progress() < 100
}

func (l *TodoList) DoneCount() int {
	count := 0
	for _, item := range l.Items {
		if item.Done {
			count++
		}
	}
	return count
}
// internal/domain/errors.go
package domain

import "errors"

var (
	ErrNotFound       = errors.New("not found")
	ErrUnauthorized   = errors.New("unauthorized")
	ErrDuplicateEmail = errors.New("email already registered")
	ErrInvalidInput   = errors.New("invalid input")
)

Database: SQLite Migrations

Three migration files set up the schema. SQLite’s FTS5 extension powers full-text search without any external search engine.

-- migrations/001_users.up.sql
CREATE TABLE users (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    display_name TEXT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at DATETIME NOT NULL,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX idx_sessions_user_id ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
-- migrations/002_notes.up.sql
CREATE TABLE notes (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    content TEXT NOT NULL DEFAULT '',
    pinned INTEGER NOT NULL DEFAULT 0,
    word_count INTEGER NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT (datetime('now')),
    updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE tags (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT NOT NULL DEFAULT 'blue',
    UNIQUE(user_id, name)
);

CREATE TABLE note_tags (
    note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
    tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (note_id, tag_id)
);

-- Full-text search index
CREATE VIRTUAL TABLE notes_fts USING fts5(
    title,
    content,
    content=notes,
    content_rowid=rowid
);

-- Keep FTS in sync
CREATE TRIGGER notes_fts_insert AFTER INSERT ON notes BEGIN
    INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;

CREATE TRIGGER notes_fts_update AFTER UPDATE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
    INSERT INTO notes_fts(rowid, title, content) VALUES (new.rowid, new.title, new.content);
END;

CREATE TRIGGER notes_fts_delete AFTER DELETE ON notes BEGIN
    INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES ('delete', old.rowid, old.title, old.content);
END;

CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_note_tags_note ON note_tags(note_id);
CREATE INDEX idx_note_tags_tag ON note_tags(tag_id);
-- migrations/003_todos.up.sql
CREATE TABLE todo_lists (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    priority INTEGER NOT NULL DEFAULT 0,
    due_date DATETIME,
    created_at DATETIME NOT NULL DEFAULT (datetime('now')),
    updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE todo_items (
    id TEXT PRIMARY KEY,
    list_id TEXT NOT NULL REFERENCES todo_lists(id) ON DELETE CASCADE,
    text TEXT NOT NULL,
    done INTEGER NOT NULL DEFAULT 0,
    position INTEGER NOT NULL DEFAULT 0,
    completed_at DATETIME,
    created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);

CREATE VIRTUAL TABLE todos_fts USING fts5(
    title,
    description,
    content=todo_lists,
    content_rowid=rowid
);

CREATE TRIGGER todos_fts_insert AFTER INSERT ON todo_lists BEGIN
    INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_update AFTER UPDATE ON todo_lists BEGIN
    INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
    INSERT INTO todos_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER todos_fts_delete AFTER DELETE ON todo_lists BEGIN
    INSERT INTO todos_fts(todos_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;

CREATE INDEX idx_todo_lists_user ON todo_lists(user_id);
CREATE INDEX idx_todo_items_list ON todo_items(list_id, position);

The FTS5 triggers keep the search index synchronized automatically. Every insert, update, and delete on notes or todo_lists updates the virtual table. No background jobs, no eventual consistency — the search index is always current.


Database Setup with modernc.org/sqlite

// internal/config/config.go
package config

import (
	"fmt"
	"time"

	"github.com/caarlos0/env/v11"
)

type Config struct {
	Port        int           `env:"PORT" envDefault:"8080"`
	DatabasePath string       `env:"DATABASE_PATH" envDefault:"noteflow.db"`
	Environment  string       `env:"ENVIRONMENT" envDefault:"development"`
	ReadTimeout  time.Duration `env:"READ_TIMEOUT" envDefault:"5s"`
	WriteTimeout time.Duration `env:"WRITE_TIMEOUT" envDefault:"10s"`
}

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) Addr() string {
	return fmt.Sprintf(":%d", c.Port)
}

func (c *Config) IsDev() bool {
	return c.Environment == "development"
}
// internal/db/db.go
package db

import (
	"database/sql"
	"fmt"

	_ "modernc.org/sqlite"
)

func Open(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}

	// SQLite performance settings
	pragmas := []string{
		"PRAGMA journal_mode=WAL",      // Write-Ahead Logging for concurrency
		"PRAGMA synchronous=NORMAL",    // Fsync only at checkpoints
		"PRAGMA foreign_keys=ON",       // Enforce FK constraints
		"PRAGMA cache_size=-64000",     // 64MB page cache
		"PRAGMA temp_store=MEMORY",     // Temp tables in memory
		"PRAGMA busy_timeout=5000",     // Wait 5s before SQLITE_BUSY
	}

	for _, pragma := range pragmas {
		if _, err := db.Exec(pragma); err != nil {
			return nil, fmt.Errorf("set pragma %s: %w", pragma, err)
		}
	}

	// SQLite with WAL supports one writer + many readers concurrently
	db.SetMaxOpenConns(1)   // Single writer prevents SQLITE_BUSY
	db.SetMaxIdleConns(1)

	return db, nil
}

The PRAGMAs matter. WAL mode allows concurrent reads while a write is happening — critical for a web application. busy_timeout=5000 makes the driver wait up to 5 seconds for a lock instead of immediately returning SQLITE_BUSY. SetMaxOpenConns(1) prevents write conflicts at the application layer.


Note Repository

// internal/note/repository.go
package note

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"

	"noteflow/internal/domain"
)

type Repository struct {
	db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
	return &Repository{db: db}
}

func (r *Repository) Create(ctx context.Context, note *domain.Note) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	_, err = tx.ExecContext(ctx,
		`INSERT INTO notes (id, user_id, title, content, pinned, word_count, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		note.ID.String(), note.UserID.String(), note.Title, note.Content,
		note.Pinned, note.WordCount, note.CreatedAt, note.UpdatedAt,
	)
	if err != nil {
		return fmt.Errorf("insert note: %w", err)
	}

	if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
		return err
	}

	return tx.Commit()
}

func (r *Repository) Update(ctx context.Context, note *domain.Note) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	res, err := tx.ExecContext(ctx,
		`UPDATE notes SET title=?, content=?, pinned=?, word_count=?, updated_at=?
		 WHERE id=? AND user_id=?`,
		note.Title, note.Content, note.Pinned, note.WordCount, time.Now(),
		note.ID.String(), note.UserID.String(),
	)
	if err != nil {
		return fmt.Errorf("update note: %w", err)
	}

	rows, _ := res.RowsAffected()
	if rows == 0 {
		return domain.ErrNotFound
	}

	// Replace all tags
	_, err = tx.ExecContext(ctx, `DELETE FROM note_tags WHERE note_id=?`, note.ID.String())
	if err != nil {
		return fmt.Errorf("delete old tags: %w", err)
	}

	if err := r.syncTags(ctx, tx, note.ID, note.UserID, note.Tags); err != nil {
		return err
	}

	return tx.Commit()
}

func (r *Repository) GetByID(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
	note := &domain.Note{}
	var id, uid string
	var pinned int

	err := r.db.QueryRowContext(ctx,
		`SELECT id, user_id, title, content, pinned, word_count, created_at, updated_at
		 FROM notes WHERE id=? AND user_id=?`,
		noteID.String(), userID.String(),
	).Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount, &note.CreatedAt, &note.UpdatedAt)
	if err == sql.ErrNoRows {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("query note: %w", err)
	}

	note.ID, _ = uuid.Parse(id)
	note.UserID, _ = uuid.Parse(uid)
	note.Pinned = pinned == 1

	tags, err := r.getNoteTags(ctx, note.ID)
	if err != nil {
		return nil, err
	}
	note.Tags = tags

	return note, nil
}

func (r *Repository) List(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
	query := `
		SELECT DISTINCT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at
		FROM notes n`

	args := []any{userID.String()}

	if tagFilter != "" {
		query += `
		JOIN note_tags nt ON n.id = nt.note_id
		JOIN tags t ON nt.tag_id = t.id AND t.name = ?`
		args = append([]any{tagFilter}, args...)
	}

	query += ` WHERE n.user_id = ? ORDER BY n.pinned DESC, n.updated_at DESC`

	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("list notes: %w", err)
	}
	defer rows.Close()

	var notes []domain.Note
	for rows.Next() {
		var note domain.Note
		var id, uid string
		var pinned int

		if err := rows.Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount, &note.CreatedAt, &note.UpdatedAt); err != nil {
			return nil, fmt.Errorf("scan note: %w", err)
		}

		note.ID, _ = uuid.Parse(id)
		note.UserID, _ = uuid.Parse(uid)
		note.Pinned = pinned == 1
		notes = append(notes, note)
	}

	// Load tags for each note in one query
	if len(notes) > 0 {
		if err := r.loadTagsForNotes(ctx, notes); err != nil {
			return nil, err
		}
	}

	return notes, nil
}

func (r *Repository) Search(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, error) {
	// Escape FTS special characters and add prefix match
	safeQuery := strings.ReplaceAll(query, `"`, `""`)
	ftsQuery := fmt.Sprintf(`"%s"*`, safeQuery)

	rows, err := r.db.QueryContext(ctx,
		`SELECT n.id, n.user_id, n.title, n.content, n.pinned, n.word_count, n.created_at, n.updated_at,
		        highlight(notes_fts, 0, '<mark>', '</mark>') as title_hl,
		        snippet(notes_fts, 1, '<mark>', '</mark>', '...', 20) as snippet
		 FROM notes_fts
		 JOIN notes n ON notes_fts.rowid = n.rowid
		 WHERE notes_fts MATCH ? AND n.user_id = ?
		 ORDER BY rank
		 LIMIT 20`,
		ftsQuery, userID.String(),
	)
	if err != nil {
		return nil, fmt.Errorf("search notes: %w", err)
	}
	defer rows.Close()

	var notes []domain.Note
	for rows.Next() {
		var note domain.Note
		var id, uid string
		var pinned int
		var titleHL, snippet string

		if err := rows.Scan(&id, &uid, &note.Title, &note.Content, &pinned, &note.WordCount,
			&note.CreatedAt, &note.UpdatedAt, &titleHL, &snippet); err != nil {
			return nil, fmt.Errorf("scan search result: %w", err)
		}

		note.ID, _ = uuid.Parse(id)
		note.UserID, _ = uuid.Parse(uid)
		note.Pinned = pinned == 1
		// Store highlighted title and snippet in HTML field (repurposed for search)
		note.HTML = fmt.Sprintf(`<span class="font-medium">%s</span><p class="text-sm text-gray-500 mt-1">%s</p>`, titleHL, snippet)
		notes = append(notes, note)
	}

	return notes, nil
}

func (r *Repository) TogglePin(ctx context.Context, noteID, userID uuid.UUID) error {
	_, err := r.db.ExecContext(ctx,
		`UPDATE notes SET pinned = NOT pinned, updated_at = datetime('now')
		 WHERE id = ? AND user_id = ?`,
		noteID.String(), userID.String(),
	)
	return err
}

func (r *Repository) Delete(ctx context.Context, noteID, userID uuid.UUID) error {
	res, err := r.db.ExecContext(ctx,
		`DELETE FROM notes WHERE id = ? AND user_id = ?`,
		noteID.String(), userID.String(),
	)
	if err != nil {
		return fmt.Errorf("delete note: %w", err)
	}
	rows, _ := res.RowsAffected()
	if rows == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func (r *Repository) GetUserTags(ctx context.Context, userID uuid.UUID) ([]domain.Tag, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT t.id, t.name, t.color, COUNT(nt.note_id) as count
		 FROM tags t
		 LEFT JOIN note_tags nt ON t.id = nt.tag_id
		 WHERE t.user_id = ?
		 GROUP BY t.id
		 ORDER BY count DESC, t.name`,
		userID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var tags []domain.Tag
	for rows.Next() {
		var tag domain.Tag
		var id string
		if err := rows.Scan(&id, &tag.Name, &tag.Color, &tag.Count); err != nil {
			return nil, err
		}
		tag.ID, _ = uuid.Parse(id)
		tags = append(tags, tag)
	}
	return tags, nil
}

func (r *Repository) syncTags(ctx context.Context, tx *sql.Tx, noteID, userID uuid.UUID, tags []domain.Tag) error {
	for _, tag := range tags {
		tagID := uuid.New()
		// Upsert tag by name
		_, err := tx.ExecContext(ctx,
			`INSERT INTO tags (id, user_id, name, color) VALUES (?, ?, ?, ?)
			 ON CONFLICT(user_id, name) DO UPDATE SET color=excluded.color`,
			tagID.String(), userID.String(), tag.Name, tag.Color,
		)
		if err != nil {
			return fmt.Errorf("upsert tag %s: %w", tag.Name, err)
		}

		// Get actual tag ID (may have been the existing one)
		var actualID string
		err = tx.QueryRowContext(ctx,
			`SELECT id FROM tags WHERE user_id=? AND name=?`,
			userID.String(), tag.Name,
		).Scan(&actualID)
		if err != nil {
			return fmt.Errorf("get tag id: %w", err)
		}

		_, err = tx.ExecContext(ctx,
			`INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)`,
			noteID.String(), actualID,
		)
		if err != nil {
			return fmt.Errorf("link tag: %w", err)
		}
	}
	return nil
}

func (r *Repository) getNoteTags(ctx context.Context, noteID uuid.UUID) ([]domain.Tag, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT t.id, t.name, t.color FROM tags t
		 JOIN note_tags nt ON t.id = nt.tag_id
		 WHERE nt.note_id = ? ORDER BY t.name`,
		noteID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var tags []domain.Tag
	for rows.Next() {
		var tag domain.Tag
		var id string
		if err := rows.Scan(&id, &tag.Name, &tag.Color); err != nil {
			return nil, err
		}
		tag.ID, _ = uuid.Parse(id)
		tags = append(tags, tag)
	}
	return tags, nil
}

func (r *Repository) loadTagsForNotes(ctx context.Context, notes []domain.Note) error {
	ids := make([]string, len(notes))
	for i, n := range notes {
		ids[i] = "'" + n.ID.String() + "'"
	}

	rows, err := r.db.QueryContext(ctx,
		fmt.Sprintf(
			`SELECT nt.note_id, t.id, t.name, t.color FROM tags t
			 JOIN note_tags nt ON t.id = nt.tag_id
			 WHERE nt.note_id IN (%s) ORDER BY t.name`,
			strings.Join(ids, ","),
		),
	)
	if err != nil {
		return err
	}
	defer rows.Close()

	tagMap := make(map[string][]domain.Tag)
	for rows.Next() {
		var noteID, tagID, name, color string
		if err := rows.Scan(&noteID, &tagID, &name, &color); err != nil {
			return err
		}
		id, _ := uuid.Parse(tagID)
		tagMap[noteID] = append(tagMap[noteID], domain.Tag{ID: id, Name: name, Color: color})
	}

	for i := range notes {
		notes[i].Tags = tagMap[notes[i].ID.String()]
	}
	return nil
}

The Search method uses FTS5’s highlight() and snippet() functions to return search results with the matching terms already wrapped in <mark> tags. The application does not need to post-process the text — SQLite handles it.

The loadTagsForNotes method loads tags for all notes in one query instead of N queries. It builds an IN clause from the note IDs and assembles the result into a map. This is the SQLite equivalent of the LEFT JOIN approach used with PostgreSQL.


Note Service: Markdown Rendering

The service layer owns the markdown rendering. When a note is fetched, the service converts the stored markdown source into sanitized HTML before the template renders it.

// internal/note/service.go
package note

import (
	"bytes"
	"context"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"

	"noteflow/internal/domain"
)

var md = goldmark.New(
	goldmark.WithExtensions(
		extension.GFM,          // GitHub Flavored Markdown: tables, strikethrough, task lists
		extension.Typographer,  // Smart quotes, dashes
	),
	goldmark.WithParserOptions(
		parser.WithAutoHeadingID(), // Anchor links on headings
	),
	goldmark.WithRendererOptions(
		html.WithHardWraps(), // Newline = <br>
		html.WithXHTML(),
	),
)

type Service struct {
	repo *Repository
}

func NewService(repo *Repository) *Service {
	return &Service{repo: repo}
}

func (s *Service) CreateNote(ctx context.Context, userID uuid.UUID, title, content string, tagNames []string) (*domain.Note, error) {
	tags := normalizeTagNames(tagNames)

	note := &domain.Note{
		ID:        uuid.New(),
		UserID:    userID,
		Title:     strings.TrimSpace(title),
		Content:   content,
		Tags:      tags,
		WordCount: countWords(content),
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	if err := s.repo.Create(ctx, note); err != nil {
		return nil, err
	}

	note.HTML = renderMarkdown(note.Content)
	return note, nil
}

func (s *Service) GetNote(ctx context.Context, noteID, userID uuid.UUID) (*domain.Note, error) {
	note, err := s.repo.GetByID(ctx, noteID, userID)
	if err != nil {
		return nil, err
	}
	note.HTML = renderMarkdown(note.Content)
	return note, nil
}

func (s *Service) ListNotes(ctx context.Context, userID uuid.UUID, tagFilter string) ([]domain.Note, error) {
	return s.repo.List(ctx, userID, tagFilter)
}

func (s *Service) UpdateNote(ctx context.Context, noteID, userID uuid.UUID, title, content string, tagNames []string, pinned bool) (*domain.Note, error) {
	note := &domain.Note{
		ID:        noteID,
		UserID:    userID,
		Title:     strings.TrimSpace(title),
		Content:   content,
		Tags:      normalizeTagNames(tagNames),
		Pinned:    pinned,
		WordCount: countWords(content),
		UpdatedAt: time.Now(),
	}

	if err := s.repo.Update(ctx, note); err != nil {
		return nil, err
	}

	note.HTML = renderMarkdown(note.Content)
	return note, nil
}

func (s *Service) SearchNotes(ctx context.Context, userID uuid.UUID, query string) ([]domain.Note, []domain.TodoList, error) {
	notes, err := s.repo.Search(ctx, userID, query)
	if err != nil {
		return nil, nil, err
	}
	return notes, nil, nil
}

func renderMarkdown(source string) string {
	var buf bytes.Buffer
	if err := md.Convert([]byte(source), &buf); err != nil {
		return "<p>Error rendering content</p>"
	}
	return buf.String()
}

func countWords(s string) int {
	return len(strings.Fields(s))
}

func normalizeTagNames(names []string) []domain.Tag {
	colors := []string{"blue", "green", "purple", "orange", "pink", "teal", "red", "yellow"}
	var tags []domain.Tag
	seen := make(map[string]bool)

	for i, name := range names {
		name = strings.ToLower(strings.TrimSpace(name))
		if name == "" || seen[name] {
			continue
		}
		seen[name] = true
		tags = append(tags, domain.Tag{
			Name:  name,
			Color: colors[i%len(colors)],
		})
	}
	return tags
}

Goldmark renders GitHub Flavored Markdown. This means users get tables, strikethrough, task lists (checkboxes in rendered markdown), and code fencing with syntax classes. The WithAutoHeadingID option adds anchor links to headings so users can link to sections within a note.


Todo Repository

// internal/todo/repository.go
package todo

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	"github.com/google/uuid"

	"noteflow/internal/domain"
)

type Repository struct {
	db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
	return &Repository{db: db}
}

func (r *Repository) CreateList(ctx context.Context, list *domain.TodoList) error {
	var dueDate any
	if list.DueDate != nil {
		dueDate = list.DueDate
	}

	_, err := r.db.ExecContext(ctx,
		`INSERT INTO todo_lists (id, user_id, title, description, priority, due_date, created_at, updated_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		list.ID.String(), list.UserID.String(), list.Title, list.Description,
		list.Priority, dueDate, list.CreatedAt, list.UpdatedAt,
	)
	return err
}

func (r *Repository) GetListWithItems(ctx context.Context, listID, userID uuid.UUID) (*domain.TodoList, error) {
	list := &domain.TodoList{}
	var id, uid string
	var priority int
	var dueDate sql.NullTime

	err := r.db.QueryRowContext(ctx,
		`SELECT id, user_id, title, description, priority, due_date, created_at, updated_at
		 FROM todo_lists WHERE id=? AND user_id=?`,
		listID.String(), userID.String(),
	).Scan(&id, &uid, &list.Title, &list.Description, &priority, &dueDate, &list.CreatedAt, &list.UpdatedAt)
	if err == sql.ErrNoRows {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("query list: %w", err)
	}

	list.ID, _ = uuid.Parse(id)
	list.UserID, _ = uuid.Parse(uid)
	list.Priority = domain.ListPriority(priority)
	if dueDate.Valid {
		list.DueDate = &dueDate.Time
	}

	items, err := r.getItems(ctx, list.ID)
	if err != nil {
		return nil, err
	}
	list.Items = items

	return list, nil
}

func (r *Repository) ListAll(ctx context.Context, userID uuid.UUID) ([]domain.TodoList, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, user_id, title, description, priority, due_date, created_at, updated_at
		 FROM todo_lists WHERE user_id = ?
		 ORDER BY updated_at DESC`,
		userID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var lists []domain.TodoList
	for rows.Next() {
		var list domain.TodoList
		var id, uid string
		var priority int
		var dueDate sql.NullTime

		if err := rows.Scan(&id, &uid, &list.Title, &list.Description, &priority, &dueDate, &list.CreatedAt, &list.UpdatedAt); err != nil {
			return nil, err
		}

		list.ID, _ = uuid.Parse(id)
		list.UserID, _ = uuid.Parse(uid)
		list.Priority = domain.ListPriority(priority)
		if dueDate.Valid {
			list.DueDate = &dueDate.Time
		}
		lists = append(lists, list)
	}

	// Load items for all lists
	for i := range lists {
		items, err := r.getItems(ctx, lists[i].ID)
		if err != nil {
			return nil, err
		}
		lists[i].Items = items
	}

	return lists, nil
}

func (r *Repository) AddItem(ctx context.Context, item *domain.TodoItem) error {
	// Get max position in this list
	var maxPos int
	r.db.QueryRowContext(ctx,
		`SELECT COALESCE(MAX(position), -1) FROM todo_items WHERE list_id = ?`,
		item.ListID.String(),
	).Scan(&maxPos)

	item.Position = maxPos + 1

	_, err := r.db.ExecContext(ctx,
		`INSERT INTO todo_items (id, list_id, text, done, position, created_at)
		 VALUES (?, ?, ?, ?, ?, ?)`,
		item.ID.String(), item.ListID.String(), item.Text, item.Done, item.Position, item.CreatedAt,
	)

	// Update list timestamp
	r.db.ExecContext(ctx,
		`UPDATE todo_lists SET updated_at = datetime('now') WHERE id = ?`,
		item.ListID.String(),
	)

	return err
}

func (r *Repository) ToggleItem(ctx context.Context, itemID, userID uuid.UUID) (*domain.TodoItem, error) {
	// Verify ownership through the list
	var listID string
	err := r.db.QueryRowContext(ctx,
		`SELECT ti.list_id FROM todo_items ti
		 JOIN todo_lists tl ON ti.list_id = tl.id
		 WHERE ti.id = ? AND tl.user_id = ?`,
		itemID.String(), userID.String(),
	).Scan(&listID)
	if err == sql.ErrNoRows {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, err
	}

	now := time.Now()
	_, err = r.db.ExecContext(ctx,
		`UPDATE todo_items SET
		   done = NOT done,
		   completed_at = CASE WHEN NOT done THEN ? ELSE NULL END
		 WHERE id = ?`,
		now, itemID.String(),
	)
	if err != nil {
		return nil, err
	}

	// Return updated item
	item := &domain.TodoItem{}
	var id, lid string
	var done int
	var completedAt sql.NullTime

	r.db.QueryRowContext(ctx,
		`SELECT id, list_id, text, done, position, completed_at, created_at FROM todo_items WHERE id = ?`,
		itemID.String(),
	).Scan(&id, &lid, &item.Text, &done, &item.Position, &completedAt, &item.CreatedAt)

	item.ID, _ = uuid.Parse(id)
	item.ListID, _ = uuid.Parse(lid)
	item.Done = done == 1
	if completedAt.Valid {
		item.CompletedAt = &completedAt.Time
	}

	return item, nil
}

func (r *Repository) DeleteItem(ctx context.Context, itemID, userID uuid.UUID) error {
	_, err := r.db.ExecContext(ctx,
		`DELETE FROM todo_items WHERE id = ? AND list_id IN (
		   SELECT id FROM todo_lists WHERE user_id = ?
		 )`,
		itemID.String(), userID.String(),
	)
	return err
}

func (r *Repository) getItems(ctx context.Context, listID uuid.UUID) ([]domain.TodoItem, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, list_id, text, done, position, completed_at, created_at
		 FROM todo_items WHERE list_id = ? ORDER BY position`,
		listID.String(),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var items []domain.TodoItem
	for rows.Next() {
		var item domain.TodoItem
		var id, lid string
		var done int
		var completedAt sql.NullTime

		if err := rows.Scan(&id, &lid, &item.Text, &done, &item.Position, &completedAt, &item.CreatedAt); err != nil {
			return nil, err
		}
		item.ID, _ = uuid.Parse(id)
		item.ListID, _ = uuid.Parse(lid)
		item.Done = done == 1
		if completedAt.Valid {
			item.CompletedAt = &completedAt.Time
		}
		items = append(items, item)
	}
	return items, nil
}

The ToggleItem method uses a SQL CASE expression to set completed_at only when the item transitions to done. Toggling a done item back to undone clears the timestamp. The ownership check happens in the same query: the JOIN to todo_lists WHERE user_id = ? ensures a user can only toggle items from their own lists.


Templates: Templ Views

Base Layout with Sidebar Navigation

// internal/view/layout.templ
package view

import "noteflow/internal/domain"

templ Layout(title string, user *domain.User, tags []domain.Tag) {
	<!DOCTYPE html>
	<html lang="en" class="h-full"
	      x-data="{ dark: localStorage.getItem('dark') === 'true', sidebarOpen: true }"
	      x-init="$watch('dark', v => { localStorage.setItem('dark', v); document.documentElement.classList.toggle('dark', v) }); 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 } — Noteflow</title>
		<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 flex bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
		<!-- Sidebar -->
		<aside :class="sidebarOpen ? 'w-64' : 'w-14'"
		       class="flex-shrink-0 h-screen bg-white dark:bg-gray-900 border-r border-gray-200
		              dark:border-gray-800 flex flex-col transition-all duration-200 overflow-hidden">
			<!-- Logo -->
			<div class="h-14 flex items-center px-4 border-b border-gray-100 dark:border-gray-800 gap-3">
				<button @click="sidebarOpen = !sidebarOpen"
				        class="w-6 h-6 flex flex-col gap-1.5 justify-center cursor-pointer">
					<span class="block h-0.5 bg-current"></span>
					<span class="block h-0.5 bg-current"></span>
					<span class="block h-0.5 bg-current"></span>
				</button>
				<span x-show="sidebarOpen" class="font-bold text-blue-600 dark:text-blue-400 text-lg">
					Noteflow
				</span>
			</div>

			<!-- Nav links -->
			<nav class="flex-1 px-2 py-4 space-y-1">
				@SidebarLink("/notes", "Notes", "note", title == "Notes")
				@SidebarLink("/todos", "Todos", "todo", title == "Todos")
				@SidebarLink("/search", "Search", "search", title == "Search")
			</nav>

			<!-- Tags section -->
			if len(tags) > 0 {
				<div x-show="sidebarOpen" class="px-3 pb-4">
					<p class="text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">
						Tags
					</p>
					for _, tag := range tags {
						<a href={ templ.SafeURL("/notes?tag=" + tag.Name) }
						   class="flex items-center justify-between py-1 px-2 rounded text-sm
						          hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
							<span class="flex items-center gap-2">
								<span class={ "w-2 h-2 rounded-full bg-" + tag.Color + "-400" }></span>
								{ tag.Name }
							</span>
							<span class="text-xs text-gray-400">{ tag.Count }</span>
						</a>
					}
				</div>
			}

			<!-- User + controls -->
			<div class="h-14 border-t border-gray-100 dark:border-gray-800 px-3 flex items-center gap-3">
				<div class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center
				            text-sm font-bold flex-shrink-0">
					{ string([]rune(user.DisplayName)[0:1]) }
				</div>
				<span x-show="sidebarOpen" class="text-sm truncate flex-1">{ user.DisplayName }</span>
				<button x-show="sidebarOpen"
				        @click="dark = !dark"
				        class="p-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-xs">
					<span x-show="!dark">Dark</span>
					<span x-show="dark">Light</span>
				</button>
			</div>
		</aside>

		<!-- Main content -->
		<div class="flex-1 flex flex-col min-w-0 h-screen overflow-hidden">
			<header class="h-14 flex items-center px-6 border-b border-gray-200 dark:border-gray-800
			               bg-white dark:bg-gray-900 gap-4 flex-shrink-0">
				<h1 class="font-semibold text-lg flex-1">{ title }</h1>
				<form hx-get="/search" hx-target="#content" hx-trigger="input changed delay:300ms"
				      class="relative">
					<input type="text" name="q" placeholder="Search..."
					       class="pl-8 pr-4 py-1.5 text-sm border border-gray-200 dark:border-gray-700
					              rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500
					              outline-none w-56"/>
					<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm">s</span>
				</form>
				<form hx-post="/logout">
					<button type="submit"
					        class="text-sm text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400">
						Logout
					</button>
				</form>
			</header>
			<main id="content" class="flex-1 overflow-y-auto p-6">
				{ children... }
			</main>
		</div>
	</body>
	</html>
}

templ SidebarLink(href, label, icon string, active bool) {
	<a href={ templ.SafeURL(href) }
	   class={ "flex items-center gap-3 px-2 py-2 rounded-lg text-sm transition-colors " +
	           sidebarLinkClass(active) }>
		<span class="w-5 h-5 flex-shrink-0 text-center">{ sidebarIcon(icon) }</span>
		<span x-show="sidebarOpen">{ label }</span>
	</a>
}

func sidebarLinkClass(active bool) string {
	if active {
		return "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-medium"
	}
	return "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}

func sidebarIcon(icon string) string {
	switch icon {
	case "note":
		return "N"
	case "todo":
		return "T"
	case "search":
		return "S"
	default:
		return "?"
	}
}

Notes List View

// internal/view/note/list.templ
package note

import (
	"fmt"
	"noteflow/internal/domain"
	"noteflow/internal/view"
)

templ ListPage(user *domain.User, notes []domain.Note, tags []domain.Tag, activeTag string) {
	@view.Layout("Notes", user, tags) {
		<div class="max-w-5xl mx-auto">
			<!-- Header actions -->
			<div class="flex items-center justify-between mb-6">
				<div class="flex items-center gap-2">
					if activeTag != "" {
						<span class="text-sm text-gray-500 dark:text-gray-400">
							Filtered by:
						</span>
						<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700
						            dark:text-blue-400 rounded-full text-sm font-medium">
							{ activeTag }
						</span>
						<a href="/notes" class="text-xs text-gray-400 hover:text-gray-600">Clear</a>
					}
					<span class="text-sm text-gray-400">
						{ fmt.Sprintf("%d notes", len(notes)) }
					</span>
				</div>
				<a href="/notes/new"
				   class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors">
					New Note
				</a>
			</div>

			if len(notes) == 0 {
				@view.EmptyState("No notes yet", "Create your first note to get started.")
			} else {
				<!-- Pinned notes -->
				@pinnedSection(notes)

				<!-- All notes grid -->
				<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
					for _, n := range notes {
						if !n.Pinned {
							@NoteCard(n)
						}
					}
				</div>
			}
		</div>
	}
}

templ pinnedSection(notes []domain.Note) {
	for _, n := range notes {
		if n.Pinned {
			<div class="mb-6">
				<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3">Pinned</p>
				<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
					for _, pinned := range notes {
						if pinned.Pinned {
							@NoteCard(pinned)
						}
					}
				</div>
			</div>
			break
		}
	}
}

templ NoteCard(n domain.Note) {
	<div class="group bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800
	            p-4 hover:shadow-md dark:hover:shadow-black/30 transition-shadow relative">
		if n.Pinned {
			<span class="absolute top-3 right-3 text-amber-400 text-xs">pin</span>
		}
		<a href={ templ.SafeURL("/notes/" + n.ID.String()) } class="block">
			<h2 class="font-semibold text-gray-900 dark:text-gray-100 mb-1 truncate pr-6">
				{ n.Title }
			</h2>
			<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-3 leading-relaxed">
				{ n.Excerpt(160) }
			</p>
		</a>
		if len(n.Tags) > 0 {
			<div class="flex flex-wrap gap-1.5 mt-3">
				for _, tag := range n.Tags {
					@TagBadge(tag)
				}
			</div>
		}
		<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
			<span class="text-xs text-gray-400">
				{ fmt.Sprintf("%d words", n.WordCount) }
			</span>
			<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
				<button hx-post={ "/notes/" + n.ID.String() + "/pin" }
				        hx-target="closest div.group"
				        hx-swap="outerHTML"
				        class="text-xs text-gray-400 hover:text-amber-500">
					{ pinLabel(n.Pinned) }
				</button>
				<a href={ templ.SafeURL("/notes/" + n.ID.String() + "/edit") }
				   class="text-xs text-gray-400 hover:text-blue-600">Edit</a>
				<button hx-delete={ "/notes/" + n.ID.String() }
				        hx-confirm="Delete this note?"
				        hx-target="closest div.group"
				        hx-swap="outerHTML swap:0.2s"
				        class="text-xs text-gray-400 hover:text-red-500">
					Delete
				</button>
			</div>
		</div>
	</div>
}

templ TagBadge(tag domain.Tag) {
	<span class={ "px-2 py-0.5 rounded-full text-xs font-medium bg-" + tag.Color + "-100 text-" +
	             tag.Color + "-700 dark:bg-" + tag.Color + "-900/30 dark:text-" + tag.Color + "-400" }>
		{ tag.Name }
	</span>
}

func pinLabel(pinned bool) string {
	if pinned {
		return "Unpin"
	}
	return "Pin"
}

Note Form with Tag Input

// internal/view/note/form.templ
package note

import "noteflow/internal/domain"
import "noteflow/internal/view"

templ FormPage(user *domain.User, tags []domain.Tag, note *domain.Note, isEdit bool) {
	@view.Layout(formTitle(isEdit), user, tags) {
		<div class="max-w-3xl mx-auto" x-data={ tagInputData(note) }>
			<form hx-post={ formAction(note, isEdit) }
			      hx-push-url="true"
			      class="space-y-5">
				<!-- Title -->
				<div>
					<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
						Title
					</label>
					<input type="text" id="title" name="title"
					       value={ noteTitle(note) }
					       placeholder="Note title..."
					       required
					       class="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-700 rounded-lg
					              bg-white dark:bg-gray-900 text-lg font-medium
					              focus:ring-2 focus:ring-blue-500 outline-none"/>
				</div>

				<!-- Tag input with Alpine.js -->
				<div>
					<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
					<div class="flex flex-wrap gap-2 p-2 border border-gray-300 dark:border-gray-700
					            rounded-lg bg-white dark:bg-gray-900 min-h-[44px]">
						<template x-for="tag in tags" :key="tag">
							<span class="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30
							            text-blue-700 dark:text-blue-400 rounded-full text-sm">
								<span x-text="tag"></span>
								<button type="button" @click="removeTag(tag)"
								        class="hover:text-red-500 text-xs leading-none">x</button>
							</span>
						</template>
						<input type="text" x-ref="tagInput"
						       @keydown.enter.prevent="addTag($refs.tagInput.value); $refs.tagInput.value = ''"
						       @keydown.comma.prevent="addTag($refs.tagInput.value); $refs.tagInput.value = ''"
						       placeholder="Add tag, press Enter..."
						       class="flex-1 min-w-32 outline-none bg-transparent text-sm py-0.5"/>
					</div>
					<!-- Hidden inputs for form submission -->
					<template x-for="tag in tags" :key="tag">
						<input type="hidden" name="tags" :value="tag"/>
					</template>
				</div>

				<!-- Markdown content -->
				<div>
					<div class="flex items-center justify-between mb-1">
						<label for="content" class="text-sm font-medium text-gray-700 dark:text-gray-300">
							Content (Markdown)
						</label>
						<div class="flex gap-2 text-xs text-gray-400">
							<button type="button"
							        x-data="{ preview: false }"
							        @click="preview = !preview"
							        class="hover:text-gray-600 dark:hover:text-gray-200">
								Toggle preview
							</button>
						</div>
					</div>
					<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
						<textarea id="content" name="content"
						          rows="20"
						          placeholder="Write in Markdown..."
						          class="w-full px-4 py-3 border border-gray-300 dark:border-gray-700
						                 rounded-lg bg-white dark:bg-gray-900 font-mono text-sm
						                 focus:ring-2 focus:ring-blue-500 outline-none resize-y
						                 leading-relaxed">
							{ noteContent(note) }
						</textarea>
						<div class="hidden lg:block border border-gray-200 dark:border-gray-700
						            rounded-lg p-4 overflow-y-auto max-h-[520px]">
							<p class="text-xs text-gray-400 mb-3 uppercase tracking-wider">Preview</p>
							<div class="prose prose-sm dark:prose-invert max-w-none"
							     hx-trigger="keyup from:#content delay:500ms"
							     hx-post="/notes/preview"
							     hx-include="#content"
							     hx-swap="innerHTML">
								if note != nil {
									<p class="text-gray-400 text-sm italic">Preview will appear here as you type.</p>
								}
							</div>
						</div>
					</div>
				</div>

				<!-- Pin toggle -->
				<div class="flex items-center gap-3">
					<input type="checkbox" id="pinned" name="pinned" value="1"
					       class="w-4 h-4 text-blue-600 rounded border-gray-300"
					       if note != nil && note.Pinned { checked }/>
					<label for="pinned" class="text-sm text-gray-700 dark:text-gray-300">Pin this note</label>
				</div>

				<!-- Actions -->
				<div class="flex items-center gap-3 pt-2">
					<button type="submit"
					        class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors">
						{ formSubmitLabel(isEdit) }
					</button>
					<a href="/notes"
					   class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
						Cancel
					</a>
				</div>
			</form>
		</div>
	}
}

func tagInputData(note *domain.Note) string {
	if note == nil || len(note.Tags) == 0 {
		return `{ tags: [], addTag(t) { t = t.trim().toLowerCase(); if (t && !this.tags.includes(t)) this.tags.push(t) }, removeTag(t) { this.tags = this.tags.filter(x => x !== t) } }`
	}

	initial := "["
	for i, tag := range note.Tags {
		if i > 0 {
			initial += ","
		}
		initial += `"` + tag.Name + `"`
	}
	initial += "]"

	return `{ tags: ` + initial + `, addTag(t) { t = t.trim().toLowerCase(); if (t && !this.tags.includes(t)) this.tags.push(t) }, removeTag(t) { this.tags = this.tags.filter(x => x !== t) } }`
}

func formTitle(isEdit bool) string {
	if isEdit {
		return "Edit Note"
	}
	return "New Note"
}

func formAction(note *domain.Note, isEdit bool) string {
	if isEdit && note != nil {
		return "/notes/" + note.ID.String()
	}
	return "/notes"
}

func formSubmitLabel(isEdit bool) string {
	if isEdit {
		return "Save changes"
	}
	return "Create note"
}

func noteTitle(note *domain.Note) string {
	if note != nil {
		return note.Title
	}
	return ""
}

func noteContent(note *domain.Note) string {
	if note != nil {
		return note.Content
	}
	return ""
}

The tag input is the most interesting Alpine.js component in this application. It manages a local array of tag strings. Pressing Enter or comma calls addTag(), which trims and deduplicates. Each tag renders as a removable badge. Hidden <input name="tags"> elements are generated via x-for so the standard HTML form submission includes them. The Go handler reads r.Form["tags"] as a slice.

Note Detail View

// internal/view/note/detail.templ
package note

import (
	"fmt"
	"noteflow/internal/domain"
	"noteflow/internal/view"
)

templ DetailPage(user *domain.User, note *domain.Note, tags []domain.Tag) {
	@view.Layout(note.Title, user, tags) {
		<div class="max-w-3xl mx-auto">
			<!-- Metadata bar -->
			<div class="flex items-center justify-between mb-6">
				<div class="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
					<a href="/notes" class="hover:text-gray-700 dark:hover:text-gray-200">Notes</a>
					<span>/</span>
					<span class="truncate max-w-xs">{ note.Title }</span>
				</div>
				<div class="flex items-center gap-3">
					<span class="text-xs text-gray-400">
						{ fmt.Sprintf("%d words", note.WordCount) }
					</span>
					<span class="text-xs text-gray-400">
						{ note.UpdatedAt.Format("Jan 2, 2006") }
					</span>
					<a href={ templ.SafeURL("/notes/" + note.ID.String() + "/edit") }
					   class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-800 hover:bg-gray-200
					          dark:hover:bg-gray-700 rounded-lg transition-colors">
						Edit
					</a>
					<button hx-post={ "/notes/" + note.ID.String() + "/pin" }
					        hx-target="#pin-btn" hx-swap="outerHTML"
					        id="pin-btn"
					        class={ "px-3 py-1.5 text-sm rounded-lg transition-colors " + pinButtonClass(note.Pinned) }>
						{ pinDetailLabel(note.Pinned) }
					</button>
				</div>
			</div>

			<!-- Tags -->
			if len(note.Tags) > 0 {
				<div class="flex flex-wrap gap-2 mb-6">
					for _, tag := range note.Tags {
						<a href={ templ.SafeURL("/notes?tag=" + tag.Name) }>
							@TagBadge(tag)
						</a>
					}
				</div>
			}

			<!-- Rendered markdown -->
			<article class="prose prose-gray dark:prose-invert max-w-none
			                prose-headings:font-bold prose-code:bg-gray-100
			                dark:prose-code:bg-gray-800 prose-code:px-1 prose-code:rounded
			                prose-pre:bg-gray-900 dark:prose-pre:bg-black
			                prose-a:text-blue-600 dark:prose-a:text-blue-400">
				@templ.Raw(note.HTML)
			</article>
		</div>
	}
}

func pinButtonClass(pinned bool) string {
	if pinned {
		return "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
	}
	return "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
}

func pinDetailLabel(pinned bool) string {
	if pinned {
		return "Pinned"
	}
	return "Pin"
}

templ.Raw(note.HTML) injects pre-rendered HTML directly. This is safe because the markdown is rendered server-side by Goldmark, which does not allow arbitrary HTML by default. The user cannot inject a <script> tag through the note content.

Todo List View with HTMX Checklist

// internal/view/todo/list.templ
package todo

import (
	"fmt"
	"noteflow/internal/domain"
	"noteflow/internal/view"
)

templ ListPage(user *domain.User, lists []domain.TodoList, tags []domain.Tag) {
	@view.Layout("Todos", user, tags) {
		<div class="max-w-4xl mx-auto">
			<div class="flex justify-between items-center mb-6">
				<span class="text-sm text-gray-400">{ fmt.Sprintf("%d lists", len(lists)) }</span>
				<a href="/todos/new"
				   class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
					New List
				</a>
			</div>

			if len(lists) == 0 {
				@view.EmptyState("No todo lists yet", "Create your first list to track tasks.")
			} else {
				<div class="space-y-4">
					for _, list := range lists {
						@TodoListCard(list)
					}
				</div>
			}
		</div>
	}
}

templ TodoListCard(list domain.TodoList) {
	<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-5"
	     id={ "list-" + list.ID.String() }>
		<!-- Header -->
		<div class="flex items-start justify-between mb-4">
			<div>
				<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
				   class="font-semibold text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400">
					{ list.Title }
				</a>
				if list.Description != "" {
					<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{ list.Description }</p>
				}
			</div>
			<div class="flex items-center gap-2 flex-shrink-0 ml-4">
				if list.Priority != domain.ListPriorityNone {
					<span class={ "text-xs px-2 py-0.5 rounded-full " + list.Priority.BadgeClass() }>
						{ list.Priority.Label() }
					</span>
				}
				if list.IsOverdue() {
					<span class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
						Overdue
					</span>
				}
			</div>
		</div>

		<!-- Progress bar -->
		if len(list.Items) > 0 {
			<div class="mb-4">
				<div class="flex items-center justify-between text-xs text-gray-400 mb-1">
					<span>{ fmt.Sprintf("%d / %d done", list.DoneCount(), len(list.Items)) }</span>
					<span>{ fmt.Sprintf("%d%%", list.Progress()) }</span>
				</div>
				<div class="h-1.5 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
					<div class={ progressBarClass(list.Progress()) }
					     style={ fmt.Sprintf("width: %d%%", list.Progress()) }></div>
				</div>
			</div>
		}

		<!-- First 3 items preview -->
		<div class="space-y-2">
			for i, item := range list.Items {
				if i < 3 {
					@TodoItemRow(item, list.ID)
				}
			}
			if len(list.Items) > 3 {
				<a href={ templ.SafeURL("/todos/" + list.ID.String()) }
				   class="block text-xs text-gray-400 hover:text-blue-600 pt-1">
					{ fmt.Sprintf("+ %d more items", len(list.Items) - 3) }
				</a>
			}
		</div>

		<!-- Quick add item -->
		<form hx-post={ "/todos/" + list.ID.String() + "/items" }
		      hx-target={ "#list-" + list.ID.String() + " .space-y-2" }
		      hx-swap="beforeend"
		      @submit="this.reset()"
		      class="mt-4 flex gap-2">
			<input type="text" name="text" placeholder="Add item..."
			       required
			       class="flex-1 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700
			              rounded-lg bg-gray-50 dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 outline-none"/>
			<button type="submit"
			        class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg">
				Add
			</button>
		</form>
	</div>
}

templ TodoItemRow(item domain.TodoItem, listID uuid.UUID) {
	<div class="flex items-start gap-3 group" id={ "item-" + item.ID.String() }>
		<button hx-post={ "/todos/items/" + item.ID.String() + "/toggle" }
		        hx-target={ "#item-" + item.ID.String() }
		        hx-swap="outerHTML"
		        class={ "w-4 h-4 mt-0.5 rounded border-2 flex-shrink-0 transition-colors " + checkboxClass(item.Done) }>
		</button>
		<span class={ "text-sm flex-1 " + itemTextClass(item.Done) }>
			{ item.Text }
		</span>
		<button hx-delete={ "/todos/items/" + item.ID.String() }
		        hx-target={ "#item-" + item.ID.String() }
		        hx-swap="outerHTML swap:0.15s"
		        class="text-xs text-gray-300 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
			x
		</button>
	</div>
}

func checkboxClass(done bool) string {
	if done {
		return "border-blue-600 bg-blue-600 text-white"
	}
	return "border-gray-300 dark:border-gray-600 hover:border-blue-500"
}

func itemTextClass(done bool) string {
	if done {
		return "line-through text-gray-400 dark:text-gray-500"
	}
	return "text-gray-700 dark:text-gray-300"
}

func progressBarClass(progress int) string {
	if progress == 100 {
		return "h-full bg-green-500 rounded-full transition-all"
	}
	if progress >= 50 {
		return "h-full bg-blue-500 rounded-full transition-all"
	}
	return "h-full bg-gray-400 rounded-full transition-all"
}

The todo checklist interaction is the cleanest example of HTMX’s model. The checkbox button sends a POST to /todos/items/{id}/toggle. The server toggles the item in the database, renders the TodoItemRow partial with the new state, and returns it. HTMX replaces the existing row with the fresh HTML. The UI update and the database update happen in the same round trip, with zero JavaScript state management.


Handlers

Note Handler with Markdown Preview

// internal/handler/note.go
package handler

import (
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"

	"noteflow/internal/auth"
	"noteflow/internal/domain"
	"noteflow/internal/note"
	viewnote "noteflow/internal/view/note"
)

type NoteHandler struct {
	service *note.Service
}

func NewNoteHandler(service *note.Service) *NoteHandler {
	return &NoteHandler{service: service}
}

func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	tagFilter := r.URL.Query().Get("tag")

	notes, err := h.service.ListNotes(r.Context(), user.ID, tagFilter)
	if err != nil {
		http.Error(w, "failed to load notes", http.StatusInternalServerError)
		return
	}

	tags, _ := h.service.GetUserTags(r.Context(), user.ID)
	viewnote.ListPage(user, notes, tags, tagFilter).Render(r.Context(), w)
}

func (h *NoteHandler) ShowNew(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	tags, _ := h.service.GetUserTags(r.Context(), user.ID)
	viewnote.FormPage(user, tags, nil, false).Render(r.Context(), w)
}

func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())

	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	n, err := h.service.CreateNote(
		r.Context(),
		user.ID,
		r.FormValue("title"),
		r.FormValue("content"),
		r.Form["tags"],
	)
	if err != nil {
		tags, _ := h.service.GetUserTags(r.Context(), user.ID)
		viewnote.FormPage(user, tags, nil, false).Render(r.Context(), w)
		return
	}

	w.Header().Set("HX-Redirect", "/notes/"+n.ID.String())
	w.WriteHeader(http.StatusOK)
}

func (h *NoteHandler) Show(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	noteID, err := uuid.Parse(chi.URLParam(r, "noteID"))
	if err != nil {
		http.Error(w, "invalid note ID", http.StatusBadRequest)
		return
	}

	n, err := h.service.GetNote(r.Context(), noteID, user.ID)
	if err != nil {
		http.Error(w, "note not found", http.StatusNotFound)
		return
	}

	tags, _ := h.service.GetUserTags(r.Context(), user.ID)
	viewnote.DetailPage(user, n, tags).Render(r.Context(), w)
}

func (h *NoteHandler) Preview(w http.ResponseWriter, r *http.Request) {
	// HTMX calls this to render markdown preview in the editor
	content := r.FormValue("content")
	html := note.RenderMarkdown(content) // exported for handler use
	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(html))
}

func (h *NoteHandler) TogglePin(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	noteID, err := uuid.Parse(chi.URLParam(r, "noteID"))
	if err != nil {
		http.Error(w, "invalid note ID", http.StatusBadRequest)
		return
	}

	if err := h.service.TogglePin(r.Context(), noteID, user.ID); err != nil {
		http.Error(w, "failed to toggle pin", http.StatusInternalServerError)
		return
	}

	// Return updated note card (HTMX swaps the card)
	n, err := h.service.GetNote(r.Context(), noteID, user.ID)
	if err != nil {
		http.Error(w, "note not found", http.StatusNotFound)
		return
	}

	viewnote.NoteCard(*n).Render(r.Context(), w)
}

func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	noteID, err := uuid.Parse(chi.URLParam(r, "noteID"))
	if err != nil {
		http.Error(w, "invalid note ID", http.StatusBadRequest)
		return
	}

	if err := h.service.Delete(r.Context(), noteID, user.ID); err != nil {
		http.Error(w, "failed to delete note", http.StatusInternalServerError)
		return
	}

	// Return empty string — HTMX removes the element
	w.WriteHeader(http.StatusOK)
}

The Preview handler is called by the editor as the user types. HTMX triggers on keyup with a 500ms debounce, sends the current markdown content to /notes/preview, and swaps the rendered HTML into the preview panel. No WebSocket, no complex editor library — just one endpoint that converts markdown to HTML.

Todo Item Toggle Handler

// internal/handler/todo.go
package handler

import (
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/google/uuid"

	"noteflow/internal/auth"
	"noteflow/internal/domain"
	"noteflow/internal/todo"
	viewtodo "noteflow/internal/view/todo"
)

type TodoHandler struct {
	service *todo.Service
}

func NewTodoHandler(service *todo.Service) *TodoHandler {
	return &TodoHandler{service: service}
}

func (h *TodoHandler) List(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	lists, err := h.service.ListAll(r.Context(), user.ID)
	if err != nil {
		http.Error(w, "failed to load lists", http.StatusInternalServerError)
		return
	}
	viewtodo.ListPage(user, lists, nil).Render(r.Context(), w)
}

func (h *TodoHandler) AddItem(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	listID, err := uuid.Parse(chi.URLParam(r, "listID"))
	if err != nil {
		http.Error(w, "invalid list ID", http.StatusBadRequest)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, "invalid form", http.StatusBadRequest)
		return
	}

	text := r.FormValue("text")
	if text == "" {
		http.Error(w, "text required", http.StatusBadRequest)
		return
	}

	item := &domain.TodoItem{
		ID:        uuid.New(),
		ListID:    listID,
		Text:      text,
		Done:      false,
		CreatedAt: time.Now(),
	}

	if err := h.service.AddItem(r.Context(), user.ID, item); err != nil {
		http.Error(w, "failed to add item", http.StatusInternalServerError)
		return
	}

	// Return just the new item row — HTMX appends it to the list
	viewtodo.TodoItemRow(*item, listID).Render(r.Context(), w)
}

func (h *TodoHandler) ToggleItem(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	itemID, err := uuid.Parse(chi.URLParam(r, "itemID"))
	if err != nil {
		http.Error(w, "invalid item ID", http.StatusBadRequest)
		return
	}

	item, err := h.service.ToggleItem(r.Context(), itemID, user.ID)
	if err != nil {
		http.Error(w, "failed to toggle item", http.StatusInternalServerError)
		return
	}

	// Return the updated item row
	viewtodo.TodoItemRow(*item, item.ListID).Render(r.Context(), w)
}

func (h *TodoHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	itemID, err := uuid.Parse(chi.URLParam(r, "itemID"))
	if err != nil {
		http.Error(w, "invalid item ID", http.StatusBadRequest)
		return
	}

	if err := h.service.DeleteItem(r.Context(), itemID, user.ID); err != nil {
		http.Error(w, "failed to delete item", http.StatusInternalServerError)
		return
	}

	// Return empty — HTMX removes the element with swap:0.15s fade
	w.WriteHeader(http.StatusOK)
}

Search Handler

// internal/handler/search.go
package handler

import (
	"net/http"

	"noteflow/internal/auth"
	"noteflow/internal/note"
	"noteflow/internal/todo"
	viewsearch "noteflow/internal/view/search"
)

type SearchHandler struct {
	noteService *note.Service
	todoService *todo.Service
}

func NewSearchHandler(noteService *note.Service, todoService *todo.Service) *SearchHandler {
	return &SearchHandler{noteService: noteService, todoService: todoService}
}

func (h *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
	user := auth.UserFromContext(r.Context())
	query := r.URL.Query().Get("q")

	if query == "" {
		w.WriteHeader(http.StatusOK)
		return
	}

	notes, err := h.noteService.SearchNotes(r.Context(), user.ID, query)
	if err != nil {
		notes = nil
	}

	todos, err := h.todoService.SearchLists(r.Context(), user.ID, query)
	if err != nil {
		todos = nil
	}

	// If request is HTMX, return just the results fragment
	if r.Header.Get("HX-Request") == "true" {
		viewsearch.ResultsFragment(notes, todos, query).Render(r.Context(), w)
		return
	}

	// Full page for direct navigation
	tags, _ := h.noteService.GetUserTags(r.Context(), user.ID)
	viewsearch.SearchPage(user, tags, notes, todos, query).Render(r.Context(), w)
}

The HX-Request header is set by HTMX on every request it makes. The search handler checks for this header to decide whether to render a full page (for direct navigation) or just the results fragment (for HTMX-triggered searches). This is the standard pattern for progressive enhancement: the same endpoint serves both HTMX partial updates and direct browser navigation.


Router

// cmd/server/main.go
package main

import (
	"context"
	"database/sql"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	_ "modernc.org/sqlite"

	"noteflow/internal/auth"
	"noteflow/internal/config"
	"noteflow/internal/db"
	"noteflow/internal/handler"
	"noteflow/internal/note"
	"noteflow/internal/todo"
)

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("config error", "err", err)
		os.Exit(1)
	}

	// Database
	database, err := db.Open(cfg.DatabasePath)
	if err != nil {
		slog.Error("database error", "err", err)
		os.Exit(1)
	}
	defer database.Close()

	// Run migrations
	if err := runMigrations(database); err != nil {
		slog.Error("migration error", "err", err)
		os.Exit(1)
	}
	slog.Info("database ready", "path", cfg.DatabasePath)

	// Dependencies
	authRepo := auth.NewRepository(database)
	authService := auth.NewService(authRepo)

	noteRepo := note.NewRepository(database)
	noteService := note.NewService(noteRepo)

	todoRepo := todo.NewRepository(database)
	todoService := todo.NewService(todoRepo)

	// Handlers
	authHandler := handler.NewAuthHandler(authService)
	noteHandler := handler.NewNoteHandler(noteService)
	todoHandler := handler.NewTodoHandler(todoService)
	searchHandler := handler.NewSearchHandler(noteService, todoService)

	// Router
	r := chi.NewRouter()

	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(15 * time.Second))
	r.Use(securityHeaders)

	// Static files
	r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))

	// 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, "/notes", http.StatusSeeOther)
		})

		// Notes
		r.Get("/notes", noteHandler.List)
		r.Get("/notes/new", noteHandler.ShowNew)
		r.Post("/notes", noteHandler.Create)
		r.Get("/notes/{noteID}", noteHandler.Show)
		r.Get("/notes/{noteID}/edit", noteHandler.ShowEdit)
		r.Post("/notes/{noteID}", noteHandler.Update)
		r.Delete("/notes/{noteID}", noteHandler.Delete)
		r.Post("/notes/{noteID}/pin", noteHandler.TogglePin)
		r.Post("/notes/preview", noteHandler.Preview)

		// Todos
		r.Get("/todos", todoHandler.List)
		r.Get("/todos/new", todoHandler.ShowNew)
		r.Post("/todos", todoHandler.Create)
		r.Get("/todos/{listID}", todoHandler.ShowDetail)
		r.Post("/todos/{listID}/items", todoHandler.AddItem)
		r.Post("/todos/items/{itemID}/toggle", todoHandler.ToggleItem)
		r.Delete("/todos/items/{itemID}", todoHandler.DeleteItem)

		// Search
		r.Get("/search", searchHandler.Search)
	})

	srv := &http.Server{
		Addr:         cfg.Addr(),
		Handler:      r,
		ReadTimeout:  cfg.ReadTimeout,
		WriteTimeout: cfg.WriteTimeout,
	}

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

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

	<-done
	slog.Info("shutting down")
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(ctx)
	slog.Info("stopped")
}

func runMigrations(database *sql.DB) error {
	driver, err := sqlite.WithInstance(database, &sqlite.Config{})
	if err != nil {
		return err
	}
	m, err := migrate.NewWithDatabaseInstance("file://migrations", "sqlite", driver)
	if err != nil {
		return err
	}
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		return err
	}
	return nil
}

func securityHeaders(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")
		next.ServeHTTP(w, r)
	})
}

go.mod

module noteflow

go 1.25

require (
	github.com/a-h/templ v0.3.906
	github.com/caarlos0/env/v11 v11.3.1
	github.com/go-chi/chi/v5 v5.2.1
	github.com/golang-migrate/migrate/v4 v4.18.3
	github.com/google/uuid v1.6.0
	github.com/yuin/goldmark v1.7.12
	golang.org/x/crypto v0.38.0
	modernc.org/sqlite v1.37.1
)

Makefile

.PHONY: dev build test templ tailwind migrate-up migrate-down clean

dev:
	air

build: templ tailwind
	go build -ldflags="-s -w" -o bin/noteflow ./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:
	go run ./cmd/migrate up

migrate-down:
	go run ./cmd/migrate down 1

clean:
	rm -f bin/noteflow noteflow.db
	rm -f static/css/output.css
	find internal/view -name "*_templ.go" -delete

setup:
	go install github.com/a-h/templ/cmd/templ@latest
	go install github.com/air-verse/air@latest
	npm install
	cp .env.example .env

Testing

// internal/domain/todo_test.go
package domain

import (
	"testing"
	"time"
)

func TestTodoList_Progress(t *testing.T) {
	tests := []struct {
		name     string
		items    []TodoItem
		expected int
	}{
		{
			name:     "empty list returns 0",
			items:    []TodoItem{},
			expected: 0,
		},
		{
			name: "all done returns 100",
			items: []TodoItem{
				{Done: true},
				{Done: true},
			},
			expected: 100,
		},
		{
			name: "half done returns 50",
			items: []TodoItem{
				{Done: true},
				{Done: false},
			},
			expected: 50,
		},
		{
			name: "one of three done returns 33",
			items: []TodoItem{
				{Done: true},
				{Done: false},
				{Done: false},
			},
			expected: 33,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			list := &TodoList{Items: tt.items}
			if got := list.Progress(); got != tt.expected {
				t.Errorf("Progress() = %d, want %d", got, tt.expected)
			}
		})
	}
}

func TestTodoList_IsOverdue(t *testing.T) {
	past := time.Now().Add(-24 * time.Hour)
	future := time.Now().Add(24 * time.Hour)

	tests := []struct {
		name     string
		dueDate  *time.Time
		progress int
		expected bool
	}{
		{"no due date is never overdue", nil, 0, false},
		{"past due with incomplete is overdue", &past, 50, true},
		{"past due but 100% done is not overdue", &past, 100, false},
		{"future due date is not overdue", &future, 0, false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Build items to match the progress percentage
			items := itemsForProgress(tt.progress)
			list := &TodoList{DueDate: tt.dueDate, Items: items}

			if got := list.IsOverdue(); got != tt.expected {
				t.Errorf("IsOverdue() = %v, want %v", got, tt.expected)
			}
		})
	}
}

func itemsForProgress(pct int) []TodoItem {
	if pct == 0 {
		return []TodoItem{{Done: false}}
	}
	if pct == 100 {
		return []TodoItem{{Done: true}}
	}
	// 50%: one done, one undone
	return []TodoItem{{Done: true}, {Done: false}}
}
// internal/note/service_test.go
package note

import (
	"testing"
)

func TestCountWords(t *testing.T) {
	tests := []struct {
		input    string
		expected int
	}{
		{"", 0},
		{"hello world", 2},
		{"  lots   of   spaces  ", 3},
		{"one", 1},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			if got := countWords(tt.input); got != tt.expected {
				t.Errorf("countWords(%q) = %d, want %d", tt.input, got, tt.expected)
			}
		})
	}
}

func TestNormalizeTagNames(t *testing.T) {
	names := []string{"Go", "  golang ", "GO", "backend"}
	tags := normalizeTagNames(names)

	// "Go", "golang" are different after lowercase, "GO" deduplicates with "go"
	// Expected: "go", "golang", "backend" (3 unique, lowercased)
	if len(tags) != 3 {
		t.Errorf("expected 3 unique tags, got %d", len(tags))
	}

	for _, tag := range tags {
		if tag.Name != tag.Name { // already lowercase from normalization
			t.Errorf("tag name %q should be lowercase", tag.Name)
		}
	}
}

func TestRenderMarkdown(t *testing.T) {
	input := "## Hello\n\nThis is **bold** text."
	output := renderMarkdown(input)

	if output == "" {
		t.Error("renderMarkdown returned empty string")
	}

	// Should contain the heading and emphasis
	if !contains(output, "<h2") {
		t.Error("expected h2 heading in output")
	}
	if !contains(output, "<strong>") {
		t.Error("expected strong tag in output")
	}
}

func contains(s, substr string) bool {
	return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}

func containsHelper(s, sub string) bool {
	for i := 0; i <= len(s)-len(sub); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}

Dockerfile

FROM golang:1.25-alpine AS builder

WORKDIR /app

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

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

COPY . .
RUN templ generate
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /noteflow ./cmd/server

FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app
COPY --from=builder /noteflow .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/static ./static

VOLUME ["/app/data"]
ENV DATABASE_PATH=/app/data/noteflow.db

EXPOSE 8080
ENTRYPOINT ["./noteflow"]

The database volume at /app/data persists the SQLite file across container restarts. This is the entire persistence layer — one file, one volume mount.


What Makes This Different from a REST API Post

The Go Chi + SQLite REST API post built a JSON API. This post builds the user interface that a real person would open in a browser. The differences are fundamental:

REST APIFull Stack Go
Returns JSONReturns HTML
Client renders UIServer renders UI
Requires a separate frontend projectOne binary, one deployment
Needs CORS configurationNo CORS — same origin
State management on clientState on server + in database
Type mismatch between layersGo types → Templ → HTML (one type system)

The full-stack approach is not always right. When you need a mobile client alongside a web client, a JSON API is unavoidable. When you need client-side routing with complex state, a JavaScript framework makes sense. But for most internal tools, admin panels, notes apps, dashboards, and team utilities, the full-stack Go approach ships faster, runs faster, and requires far less infrastructure.


The Authoring Experience

The markdown preview panel in the note editor updates as you type. There is no debounce visible to the user — HTMX batches the keystrokes (500ms delay) and sends one request. The server renders the markdown and returns the HTML fragment. The preview panel swaps. From the user’s perspective, it feels like a live preview built with a complex JavaScript editor library. From the developer’s perspective, it is fifteen lines of HTMX attributes and one Go endpoint.

This is the productivity argument for full-stack Go: the features that look complex to build are often simple when the server owns the rendering. The markdown preview, the instant search, the checkbox toggle, the pinned note badge — every interaction is one HTTP request, one database query, and one Templ render. No state synchronization, no serialization, no client-side data model to keep in sync with the server.

A codebase that fits in one person’s head ships faster than one that requires a team to translate between layers. Full-stack Go is not a compromise — it is a choice to optimize for what actually matters: building something useful and keeping it working.

Tags

#golang #go #full-stack #architecture #best-practices #guide #tutorial #performance #security