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.
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
| Layer | Technology | Why |
|---|---|---|
| Router | Chi | Composable middleware, stdlib http.Handler |
| Templates | Templ | Compiled, type-safe, IDE-supported |
| Reactivity | HTMX | HTML fragments, no JSON serialization |
| Client state | Alpine.js | Dropdowns, tag inputs, local toggles |
| Styling | Tailwind CSS | Utility-first, dark mode built-in |
| Database | SQLite + modernc.org/sqlite | Zero dependencies, single-file DB |
| Auth | bcrypt + secure cookies | Session-based, simple and correct |
| Markdown | github.com/yuin/goldmark | Safe HTML rendering from user content |
| Search | SQLite FTS5 | Full-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, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount, ¬e.CreatedAt, ¬e.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, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount, ¬e.CreatedAt, ¬e.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, ¬e.Title, ¬e.Content, &pinned, ¬e.WordCount,
¬e.CreatedAt, ¬e.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(¬eID, &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 API | Full Stack Go |
|---|---|
| Returns JSON | Returns HTML |
| Client renders UI | Server renders UI |
| Requires a separate frontend project | One binary, one deployment |
| Needs CORS configuration | No CORS — same origin |
| State management on client | State on server + in database |
| Type mismatch between layers | Go 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
Related Articles
Organizational Health Through Architecture: Building Alignment, Trust & Healthy Culture
Learn how architecture decisions shape organizational culture, health, and alignment. Discover how to use architecture as a tool for building trust, preventing silos, enabling transparency, and creating sustainable organizational growth.
Team Health & Burnout Prevention: How Architecture Decisions Impact Human Well-being
Master the human side of architecture. Learn to recognize burnout signals, architect sustainable systems, build psychological safety, and protect team health. Because healthy teams build better systems.
Difficult Conversations & Conflict Resolution: Navigating Disagreement, Politics & Defensive Teams
Master the art of having difficult conversations as an architect. Learn how to manage technical disagreements, handle defensive teams, say no effectively, and navigate organizational politics without damaging relationships.