Tutorial

Build a Bookmark Manager

We keep this app small on purpose. By the end you will have auth, a database-backed resource, validated forms, and HTMX updates, which is enough to show how the stack fits together without hiding the code in a giant demo project.

01

Scaffold the project

Start by installing the HAMR CLI and scaffolding a new project. We'll call it marker.

terminal
go install github.com/FyrmForge/hamr/cmd/hamr@latest

hamr new marker \
  --module github.com/yourname/marker \
  --css tailwind \
  --storage local

cd marker
make install
hamr dev

Open http://localhost:3000. You should see the default HAMR welcome page. The dev server is now watching files, running templ codegen, restarting your binary on changes, and hot-reloading the browser.

02

Add the bookmarks table

Create a migration for the bookmarks table. Migrations live in internal/db/migrations/ with sequential numeric prefixes.

0002_create_bookmarks.up.sql
CREATE TABLE bookmarks (
    id          TEXT PRIMARY KEY,
    user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title       TEXT NOT NULL,
    url         TEXT NOT NULL,
    description TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX bookmarks_user_id_idx ON bookmarks(user_id);
0002_create_bookmarks.down.sql
DROP TABLE bookmarks;

The migration runs automatically on startup, but you can re-run them explicitly with make migrate.

03

Create the model and repo

Add a Bookmark struct to internal/db/models.go:

internal/db/models.go
type Bookmark struct {
    ID          string    `db:"id"`
    UserID      string    `db:"user_id"`
    Title       string    `db:"title"`
    URL         string    `db:"url"`
    Description string    `db:"description"`
    CreatedAt   time.Time `db:"created_at"`
}

Add the repo interface in internal/repo/repo.go:

internal/repo/repo.go
type BookmarkRepo interface {
    AllForUser(ctx context.Context, userID string) ([]Bookmark, error)
    Create(ctx context.Context, b *Bookmark) error
    Delete(ctx context.Context, id, userID string) error
}

Implement it in internal/repo/postgres/bookmarks.go:

internal/repo/postgres/bookmarks.go
package postgres

import (
    "context"
    "github.com/google/uuid"
    "github.com/yourname/marker/internal/repo"
)

func (s *Store) AllForUser(ctx context.Context, userID string) ([]repo.Bookmark, error) {
    var bookmarks []repo.Bookmark
    err := s.db.SelectContext(ctx, &bookmarks,
        `SELECT id, user_id, title, url, description, created_at FROM bookmarks WHERE user_id = $1 ORDER BY created_at DESC`,
        userID,
    )
    return bookmarks, err
}

func (s *Store) Create(ctx context.Context, b *repo.Bookmark) error {
    if b.ID == "" {
        b.ID = uuid.NewString()
    }
    _, err := s.db.ExecContext(ctx,
        `INSERT INTO bookmarks (id, user_id, title, url, description) VALUES ($1, $2, $3, $4, $5)`,
        b.ID, b.UserID, b.Title, b.URL, b.Description,
    )
    return err
}

func (s *Store) Delete(ctx context.Context, id, userID string) error {
    _, err := s.db.ExecContext(ctx,
        "DELETE FROM bookmarks WHERE id = $1 AND user_id = $2",
        id, userID,
    )
    return err
}
04

Build the handler

Create the handler package. Each handler lives in its own folder under internal/web/handler/.

internal/web/handler/bookmarks/handler.go
package bookmarks

import (
    "net/http"

    "github.com/FyrmForge/hamr/pkg/middleware"
    "github.com/FyrmForge/hamr/pkg/respond"
    "github.com/labstack/echo/v4"

    "github.com/yourname/marker/internal/repo"
)

type handler struct {
    repo repo.BookmarkRepo
}

func NewHandler(r repo.BookmarkRepo) *handler {
    return &handler{repo: r}
}

// GET /bookmarks
func (h *handler) List(c echo.Context) error {
    user := middleware.GetSubject(c).(*repo.User)
    bookmarks, err := h.repo.AllForUser(c.Request().Context(), user.ID)
    if err != nil {
        return respond.Error(c, err)
    }
    return respond.HTML(c, http.StatusOK, listPage(c, bookmarks))
}

// POST /bookmarks
func (h *handler) Create(c echo.Context) error {
    user := middleware.GetSubject(c).(*repo.User)
    bookmark := &repo.Bookmark{
        UserID:      user.ID,
        Title:       c.FormValue("title"),
        URL:         c.FormValue("url"),
        Description: c.FormValue("description"),
    }

    if err := FormRules.Validate(map[string]string{
        "title": bookmark.Title,
        "url":   bookmark.URL,
    }); err != nil {
        return respond.ValidationError(c, err, listPage(c, nil))
    }

    if err := h.repo.Create(c.Request().Context(), bookmark); err != nil {
        return respond.Error(c, err)
    }
    return respond.Redirect(c, "/bookmarks")
}

// POST /bookmarks/:id/delete
func (h *handler) Delete(c echo.Context) error {
    user := middleware.GetSubject(c).(*repo.User)
    if err := h.repo.Delete(c.Request().Context(), c.Param("id"), user.ID); err != nil {
        return respond.Error(c, err)
    }
    return respond.Redirect(c, "/bookmarks")
}
05

Add the validation rules

internal/web/handler/bookmarks/form_rules.go
package bookmarks

import "github.com/FyrmForge/hamr/pkg/validate"

var FormRules = validate.Form{
    Fields: map[string]validate.Field{
        "title": {
            Validators: []validate.Validator{
                validate.Required,
                validate.MaxLength(200),
            },
        },
        "url": {
            Validators: []validate.Validator{
                validate.Required,
                validate.URL,
            },
        },
    },
}
06

Build the template

internal/web/handler/bookmarks/list.templ
package bookmarks

import (
    "github.com/labstack/echo/v4"
    "github.com/yourname/marker/internal/web/components"
    "github.com/yourname/marker/internal/web/components/form"
    "github.com/yourname/marker/internal/repo"
)

templ listPage(c echo.Context, bookmarks []repo.Bookmark) {
    @components.Layout(c, "Bookmarks") {
        <main class="max-w-2xl mx-auto px-6 py-12">
            <h1 class="text-3xl font-bold mb-6">Your bookmarks</h1>

            <form method="POST" action="/bookmarks" class="mb-8 space-y-3">
                @form.CSRFField(c)
                <input type="text" name="title" placeholder="Title"
                    class="w-full px-3 py-2 rounded bg-slate-800 border border-slate-700"/>
                <input type="url" name="url" placeholder="https://..."
                    class="w-full px-3 py-2 rounded bg-slate-800 border border-slate-700"/>
                <textarea name="description" placeholder="Notes (optional)"
                    class="w-full px-3 py-2 rounded bg-slate-800 border border-slate-700"></textarea>
                <button type="submit"
                    class="px-4 py-2 rounded bg-orange-500 text-white font-semibold">
                    Save bookmark
                </button>
            </form>

            <ul class="space-y-3">
                for _, b := range bookmarks {
                    <li class="p-4 rounded border border-slate-700 flex items-start justify-between">
                        <div>
                            <a href={ templ.SafeURL(b.URL) } class="font-semibold text-orange-400">
                                { b.Title }
                            </a>
                            if b.Description != "" {
                                <p class="text-sm text-slate-400">{ b.Description }</p>
                            }
                        </div>
                        <form method="POST" action={ templ.SafeURL("/bookmarks/" + b.ID + "/delete") }>
                            @form.CSRFField(c)
                            <button type="submit" class="text-red-400 text-sm">Delete</button>
                        </form>
                    </li>
                }
            </ul>
        </main>
    }
}
07

Wire up the routes

Open internal/web/server.go and register the new handler. Bookmarks are user-scoped, so they require auth.

internal/web/server.go
import "github.com/yourname/marker/internal/web/handler/bookmarks"

// inside RegisterRoutes:
bookmarksHandler := bookmarks.NewHandler(deps.Store)

site.GET("/bookmarks", bookmarksHandler.List, requireAuth)
site.POST("/bookmarks", bookmarksHandler.Create, requireAuth)
site.POST("/bookmarks/:id/delete", bookmarksHandler.Delete, requireAuth)
08

Try it out

Save your files. The dev server will pick up the changes, run templ generate, rebuild, and reload your browser automatically.

  1. Visit http://localhost:3000/register and create an account.
  2. Navigate to /bookmarks and add a few links.
  3. Try submitting an empty form — you'll see validation errors.
  4. Delete a bookmark and watch the page update.
At this point you have a working feature.
Not just a scaffold - a real page, real data, real auth, and the usual web-app plumbing already in place.

Next steps