Getting Started

Build a filterable, sortable table of dog breeds with Go + Templ + HTMX + Alpine.js. No Node.js, no bundlers.

1. Create your project

bash
mkdir dog-breeds && cd dog-breeds
go mod init dog-breeds
go get github.com/a-h/templ@latest
go get github.com/araihu/goshtoso@latest

# Extract Goshtoso CSS (themes, component styles, utilities)
go run github.com/araihu/goshtoso/cmd/goshtoso@latest -out=goshtoso.css

2. Create the page template

Create page.templ — Alpine.js handles the filter bar state, HTMX loads and swaps table rows from the server:

page.templ
package main

import "github.com/araihu/goshtoso/components/table"

// Page renders the dog breeds page using the Goshtoso table component.
// Filtering, sorting, and pagination are all built into the component —
// just configure them via table.Config.
//
// Assets (CSS, Alpine.js, HTMX) are served from /assets/ via the embedded
// assets.Handler() — no CDN needed, works fully offline.
templ Page(cfg table.Config) {
	<!DOCTYPE html>
	<html lang="en" x-data x-init="
		const t = localStorage.getItem('theme') || 'minimal';
		document.documentElement.setAttribute('data-theme', t);
		if (localStorage.getItem('darkMode') === 'true') document.documentElement.classList.add('dark');
	">
		<head>
			<meta charset="UTF-8"/>
			<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
			<title>Dog Breeds — Goshtoso Getting Started</title>
			<!-- Embedded Goshtoso assets (CSS with themes + JS) -->
			<link rel="stylesheet" href="/assets/styles.css"/>
			<script defer src="/assets/js/vendor/alpine-collapse.min.js"></script>
			<script defer src="/assets/js/vendor/alpine-focus.min.js"></script>
			<script defer src="/assets/js/vendor/alpine.min.js"></script>
			<script src="/assets/js/vendor/htmx.min.js"></script>
			<script src="/assets/js/darkmode.js"></script>
			<style>[x-cloak] { display: none !important; }</style>
		</head>
		<body class="min-h-screen bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark">
			<div class="max-w-5xl mx-auto px-6 py-12">
				<h1 class="text-3xl font-bold font-title mb-2 text-on-surface-strong dark:text-on-surface-dark-strong">Dog Breeds</h1>
				<p class="text-on-surface-muted dark:text-on-surface-dark-muted mb-8">
					A filterable, sortable, paginated table built with
					<a href="https://github.com/araihu/goshtoso" class="underline text-primary dark:text-primary-dark">Goshtoso</a> —
					Go + Alpine.js + Tailwind CSS + Templ + HTMX.
				</p>
				@table.Table(cfg)
				<p class="mt-6 text-xs text-on-surface-muted dark:text-on-surface-dark-muted text-center">
					Built with <a href="https://github.com/araihu/goshtoso" class="underline">Goshtoso</a>
				</p>
			</div>
		</body>
	</html>
}

3. Create the server

Create main.go — serves the page and handles the HTMX API endpoint for filtering, searching, and sorting:

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"sort"
	"strconv"
	"strings"

	"github.com/araihu/goshtoso/assets"
	"github.com/araihu/goshtoso/components/table"
)

// Dog represents a dog breed with metadata
type Dog struct {
	Breed       string
	Group       string
	Origin      string
	Size        string
	Temperament string
}

var breeds = []Dog{
	{Breed: "Labrador Retriever", Group: "Sporting", Origin: "Canada", Size: "Large", Temperament: "Friendly"},
	{Breed: "German Shepherd", Group: "Herding", Origin: "Germany", Size: "Large", Temperament: "Loyal"},
	{Breed: "Golden Retriever", Group: "Sporting", Origin: "Scotland", Size: "Large", Temperament: "Gentle"},
	{Breed: "French Bulldog", Group: "Non-Sporting", Origin: "France", Size: "Small", Temperament: "Playful"},
	{Breed: "Bulldog", Group: "Non-Sporting", Origin: "England", Size: "Medium", Temperament: "Calm"},
	{Breed: "Poodle", Group: "Non-Sporting", Origin: "Germany", Size: "Medium", Temperament: "Intelligent"},
	{Breed: "Beagle", Group: "Hound", Origin: "England", Size: "Small", Temperament: "Curious"},
	{Breed: "Rottweiler", Group: "Working", Origin: "Germany", Size: "Large", Temperament: "Confident"},
	{Breed: "Dachshund", Group: "Hound", Origin: "Germany", Size: "Small", Temperament: "Clever"},
	{Breed: "Yorkshire Terrier", Group: "Toy", Origin: "England", Size: "Small", Temperament: "Spirited"},
	{Breed: "Boxer", Group: "Working", Origin: "Germany", Size: "Large", Temperament: "Energetic"},
	{Breed: "Siberian Husky", Group: "Working", Origin: "Russia", Size: "Medium", Temperament: "Outgoing"},
	{Breed: "Shih Tzu", Group: "Toy", Origin: "China", Size: "Small", Temperament: "Affectionate"},
	{Breed: "Border Collie", Group: "Herding", Origin: "Scotland", Size: "Medium", Temperament: "Smart"},
	{Breed: "Doberman", Group: "Working", Origin: "Germany", Size: "Large", Temperament: "Alert"},
	{Breed: "Corgi", Group: "Herding", Origin: "Wales", Size: "Small", Temperament: "Happy"},
	{Breed: "Australian Shepherd", Group: "Herding", Origin: "USA", Size: "Medium", Temperament: "Active"},
	{Breed: "Cavalier King Charles", Group: "Toy", Origin: "England", Size: "Small", Temperament: "Graceful"},
	{Breed: "Great Dane", Group: "Working", Origin: "Germany", Size: "Large", Temperament: "Patient"},
	{Breed: "Chihuahua", Group: "Toy", Origin: "Mexico", Size: "Small", Temperament: "Charming"},
}

// columns defines the table headers
func columns() []table.Column {
	return []table.Column{
		{Key: "breed", Label: "Breed", Sortable: true},
		{Key: "group", Label: "Group", Sortable: true},
		{Key: "origin", Label: "Origin", Sortable: true},
		{Key: "size", Label: "Size", Sortable: true},
		{Key: "temperament", Label: "Temperament"},
	}
}

// filters defines the built-in filter bar controls
func filters() *table.FilterConfig {
	return &table.FilterConfig{
		Collapsible:       true,
		InitiallyExpanded: true,
		Filters: []table.Filter{
			{
				Key:         "search",
				Label:       "Search",
				Type:        table.FilterSearch,
				Placeholder: "Search breeds, origins, temperaments...",
			},
			{
				Key:   "group",
				Label: "Group",
				Type:  table.FilterSelect,
				Options: []table.FilterOption{
					{Value: "", Label: "All Groups"},
					{Value: "Sporting", Label: "Sporting"},
					{Value: "Herding", Label: "Herding"},
					{Value: "Hound", Label: "Hound"},
					{Value: "Working", Label: "Working"},
					{Value: "Non-Sporting", Label: "Non-Sporting"},
					{Value: "Toy", Label: "Toy"},
				},
			},
		},
	}
}

// dogsToRows converts Dogs into table.Row
func dogsToRows(dogs []Dog) []table.Row {
	rows := make([]table.Row, len(dogs))
	for i, d := range dogs {
		rows[i] = table.Row{
			ID: d.Breed,
			Cells: map[string]table.Cell{
				"breed":       {Text: d.Breed},
				"group":       {Text: d.Group},
				"origin":      {Text: d.Origin},
				"size":        {Text: d.Size},
				"temperament": {Text: d.Temperament},
			},
		}
	}
	return rows
}

// filterAndSort applies search, group filter, and sort to the breed list
func filterAndSort(search, group, orderBy, orderDir string) []Dog {
	var filtered []Dog
	search = strings.ToLower(search)
	for _, d := range breeds {
		if group != "" && d.Group != group {
			continue
		}
		if search != "" &&
			!strings.Contains(strings.ToLower(d.Breed), search) &&
			!strings.Contains(strings.ToLower(d.Origin), search) &&
			!strings.Contains(strings.ToLower(d.Temperament), search) {
			continue
		}
		filtered = append(filtered, d)
	}
	if orderBy != "" {
		sort.SliceStable(filtered, func(i, j int) bool {
			var a, b string
			switch orderBy {
			case "breed":
				a, b = filtered[i].Breed, filtered[j].Breed
			case "group":
				a, b = filtered[i].Group, filtered[j].Group
			case "origin":
				a, b = filtered[i].Origin, filtered[j].Origin
			case "size":
				a, b = filtered[i].Size, filtered[j].Size
			}
			if orderDir == "desc" {
				return a > b
			}
			return a < b
		})
	}
	return filtered
}

func main() {
	const perPage = 5

	mux := http.NewServeMux()

	// Serve embedded Goshtoso assets (CSS with themes, Alpine.js, HTMX, fonts)
	mux.Handle("/assets/", assets.Handler())

	// Main page — renders the full table with filters, sorting, and pagination
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.NotFound(w, r)
			return
		}
		dogs := filterAndSort("", "", "breed", "asc")
		totalPages := (len(dogs) + perPage - 1) / perPage
		pageRows := dogsToRows(dogs[:min(perPage, len(dogs))])

		Page(table.Config{
			ID:           "breeds",
			HTMXEndpoint: "/api/breeds",
			Columns:      columns(),
			Rows:         pageRows,
			SortBy:       "breed",
			SortDir:      table.SortAsc,
			Pagination:   &table.PaginationConfig{CurrentPage: 1, TotalPages: totalPages, PerPage: perPage},
			Filters:      filters(),
		}).Render(r.Context(), w)
	})

	// HTMX endpoint — returns filtered/sorted/paginated table rows
	mux.HandleFunc("/api/breeds", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")

		q := r.URL.Query()
		search := q.Get("search")
		group := q.Get("group")
		orderBy := q.Get("order_by")
		orderDir := q.Get("order_dir")
		if orderDir == "" {
			orderDir = "asc"
		}

		dogs := filterAndSort(search, group, orderBy, orderDir)

		// Pagination
		page := 1
		pp := perPage
		if v := q.Get("page"); v != "" {
			if p, err := strconv.Atoi(v); err == nil && p > 0 {
				page = p
			}
		}
		if v := q.Get("per_page"); v != "" {
			if p, err := strconv.Atoi(v); err == nil && p > 0 {
				pp = p
			}
		}
		totalPages := (len(dogs) + pp - 1) / pp
		start := (page - 1) * pp
		if start >= len(dogs) {
			start = 0
			page = 1
		}
		end := start + pp
		if end > len(dogs) {
			end = len(dogs)
		}

		cfg := table.Config{
			ID:           "breeds",
			Columns:      columns(),
			Rows:         dogsToRows(dogs[start:end]),
			HTMXEndpoint: "/api/breeds",
			SortBy:       orderBy,
			SortDir:      table.SortDir(orderDir),
			Pagination:   &table.PaginationConfig{CurrentPage: page, TotalPages: totalPages, PerPage: pp},
		}

		// Render table rows
		for _, row := range cfg.Rows {
			table.TableRow(cfg, row).Render(r.Context(), w)
		}

		// OOB: update pagination controls
		if totalPages > 1 {
			fmt.Fprintf(w, `<div id="%s" hx-swap-oob="true" class="flex items-center justify-between border-t border-gray-200 px-4 py-3">`, cfg.PaginationID())
			fmt.Fprintf(w, `<div class="text-sm text-gray-500">Page %d of %d</div>`, page, totalPages)
			table.TablePaginationNav(cfg).Render(r.Context(), w)
			fmt.Fprintf(w, `</div>`)
		}
	})

	addr := ":3000"
	log.Printf("Dog breeds app running at http://localhost%s", addr)
	log.Fatal(http.ListenAndServe(addr, mux))
}

4. Generate and run

bash
templ generate
go run .
# Open http://localhost:3000

What you get

  • 20 dog breeds in a sortable table — click any column header
  • Live search with 300ms debounce via Alpine.js x-model
  • Group filter dropdown (Sporting, Herding, Working, Hound, Non-Sporting, Toy)
  • Server-rendered HTML fragments via HTMX — no JSON, no fetch, no client state
  • Single Go binary, zero JavaScript build tools

This site uses localStorage to remember your theme preference. No tracking cookies.