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.
Start by installing the HAMR CLI and scaffolding a new project. We'll call it marker.
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 devOpen 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.
Create a migration for the bookmarks table. Migrations live in internal/db/migrations/ with sequential numeric prefixes.
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);DROP TABLE bookmarks;The migration runs automatically on startup, but you can re-run them explicitly with make migrate.
Add a Bookmark struct to 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:
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:
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
}Create the handler package. Each handler lives in its own folder under internal/web/handler/.
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")
}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,
},
},
},
}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>
}
}Open internal/web/server.go and register the new handler. Bookmarks are user-scoped, so they require auth.
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)Save your files. The dev server will pick up the changes, run templ generate, rebuild, and reload your browser automatically.