In Part 1, we set up the basic admin UI with a landing page, a shared layout, and HTMX-enhanced navigation. In this part, we will move one step further and build a simple CRUD flow with a paginated table and an edit form.
To keep the article focused on the UI flow, the examples below use mock data. The database layer can come later.
The code samples are intentionally simplified. They are closer to pseudocode than production code, so the focus stays on structure and page behavior.
Prepare mock data
Before introducing a real database, it is useful to work with a small in-memory dataset. That makes it easier to focus on page structure, routing, and interaction patterns.
For example, a product list can be represented like this:
type Product struct {
ID int
Name string
Description string
Brand string
Category string
}
var mockProducts = []Product{
{ID: 1, Name: "iPhone 15 Pro", Description: "Flagship phone", Brand: "Apple", Category: "Phone"},
{ID: 2, Name: "Galaxy S24", Description: "Android flagship", Brand: "Samsung", Category: "Phone"},
{ID: 3, Name: "ThinkPad X1", Description: "Business laptop", Brand: "Lenovo", Category: "Laptop"},
}
Then the routes can return pages backed by that mock slice:
app.Get("/api/products", RenderTempl(ProductListPage))
app.Get("/api/products/create", RenderTempl(ProductFormPage))
app.Get("/api/products/:id", RenderTempl(ProductFormPage))
That is enough to simulate a normal CRUD flow before a real persistence layer exists.
Render a table of data
templui includes a beatiful ShadCN style table component.
Here is a simplified .templ page that renders a product table inside the shared admin layout:
templ ProductListPage(c fiber.Ctx, products []Product) {
@AdminLayout(c) {
<h1 class="text-2xl font-semibold py-3">Product Management</h1>
<div class="w-full my-1 flex items-center justify-between gap-3">
<form id="filters" hx-boost="true" hx-target="#data-table" action="" class="max-w-sm">
<input
id="search"
name="search"
type="search"
placeholder="Search products"
class="w-full"
/>
<input id="page_no" type="hidden" name="_page" value="1"/>
</form>
@button.Button(button.Props{Href: "/api/products/create"}) {
Create
}
</div>
<div id="data-table" class="mt-6">
@table.Table() {
@table.Header() {
@table.Row() {
@table.Head() { Name }
@table.Head() { Description }
@table.Head() { Brand }
@table.Head() { Category }
@table.Head() { Actions }
}
}
@table.Body() {
for _, p := range products {
@table.Row() {
@table.Cell() { { p.Name } }
@table.Cell() { { p.Description } }
@table.Cell() { { p.Brand } }
@table.Cell() { { p.Category } }
@table.Cell() {
@button.Button(button.Props{Variant: "outline", Href: fmt.Sprintf("/api/products/%d", p.ID)}) {
Edit
}
@button.Button(button.Props{
Variant: "destructive",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/api/products/%d", p.ID),
"hx-target": "closest tr",
"hx-swap": "delete",
},
}) {
Delete
}
}
}
}
}
}
</div>
}
}
Even in this simplified version, the main CRUD ideas are already visible:
- a filter form at the top
- a table in the middle
- row-level actions for edit and delete
- a container that HTMX can update without redrawing the whole page
Add pagination
For a simpler example, it is easier to use the Templ UI pagination component directly and let each page item point to a normal URL.
The component already provides the pagination structure for us:
templ ProductPagination(currentPage int, totalPages int) {
{{ p := pagination.CreatePagination(currentPage, totalPages, 5) }}
@pagination.Pagination(pagination.Props{Class: "mt-8"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Href: fmt.Sprintf("?page=%d", currentPage-1),
Disabled: !p.HasPrevious,
Label: "Previous",
})
}
for _, page := range p.Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
Href: fmt.Sprintf("?page=%d", page),
IsActive: page == p.CurrentPage,
}) {
{ page }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Href: fmt.Sprintf("?page=%d", currentPage+1),
Disabled: !p.HasNext,
Label: "Next",
})
}
}
}
}
This version is enough for a normal server-rendered page. Later, if you want pagination to update only the table area, you can keep the same Templ UI component and add HTMX attributes on top of it.
Add a form for editing
The next step is a create/edit screen. It can reuse the same component for both modes by checking whether an existing record is present.
Here is a simplified form page:
templ ProductFormPage(c fiber.Ctx, product Product, isEditing bool) {
@AdminLayout(c) {
<h1 class="text-2xl font-semibold py-3">Product Management</h1>
<div class="mt-2 flex px-4 pb-10">
@card.Card(card.Props{Class: "w-full max-w-2xl"}) {
@card.Header() {
@card.Title() {
if isEditing {
Edit Product
} else {
Create Product
}
}
}
@card.Content() {
<form
hx-put="/api/products"
hx-swap="none"
_="on htmx:afterRequest
if event.detail.successful then
set window.location.href to '/api/products'
end"
>
<input type="hidden" name="id" value={ fmt.Sprintf("%d", product.ID) }/>
@form.Item() {
@form.Label(form.LabelProps{For: "name"}) {
Name
}
@input.Input(input.Props{ID: "name", Name: "name", Value: product.Name})
}
<div class="my-4"></div>
@form.Item() {
@form.Label(form.LabelProps{For: "description"}) {
Description
}
@input.Input(input.Props{ID: "description", Name: "description", Value: product.Description})
}
<div class="my-4"></div>
@form.Item() {
@form.Label(form.LabelProps{For: "brand"}) {
Brand
}
@input.Input(input.Props{ID: "brand", Name: "brand", Value: product.Brand})
}
<div class="my-4"></div>
@form.Item() {
@form.Label(form.LabelProps{For: "category"}) {
Category
}
@input.Input(input.Props{ID: "category", Name: "category", Value: product.Category})
}
<div class="my-6"></div>
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</form>
}
}
</div>
}
}
This pattern is straightforward:
- when
isEditingisfalse, the form behaves like a create page - when
isEditingistrue, the form is prefilled and behaves like an edit page - the same fields can be reused in both cases
Use HTMX to wire things up
At this point, the CRUD pages already exist. HTMX makes them feel more connected without introducing a client-side SPA architecture.
There are three useful patterns here.
First, the filter form can target only the table area:
templ ProductFilterBar() {
<form id="filters" hx-boost="true" hx-target="#data-table" action="">
<input name="search" type="search" placeholder="Search products"/>
<input id="page_no" type="hidden" name="_page" value="1"/>
</form>
}
Second, row deletion can update the table immediately:
templ DeleteProductButton(id int) {
@button.Button(button.Props{
Variant: "destructive",
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/api/products/%d", id),
"hx-target": "closest tr",
"hx-swap": "delete",
},
}) {
Delete
}
}
Third, the form can submit asynchronously and redirect only after success:
templ SaveProductFormUI(product Product) {
<form
hx-put="/api/products"
hx-swap="none"
class="space-y-4"
_="on htmx:afterRequest
if event.detail.successful then
set window.location.href to '/api/products'
end"
>
<input type="hidden" name="id" value={ fmt.Sprintf("%d", product.ID) }/>
@form.Item() {
@form.Label(form.LabelProps{For: "name"}) {
Name
}
@input.Input(input.Props{
ID: "name",
Name: "name",
Value: product.Name,
})
@form.Description() {
Enter the product name shown in the table.
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "brand"}) {
Brand
}
@input.Input(input.Props{
ID: "brand",
Name: "brand",
Value: product.Brand,
})
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</form>
}
This version is a little more verbose, but it scales better once the form grows. Labels, descriptions, validation messages, and spacing can all follow the same structure.
The overall result is still server-rendered and HTML-first, but it feels much more interactive.
Part 2 Summary
At this point, we have a practical CRUD UI structure:
- mock data for quick iteration
- a product table page
- pagination as a reusable component
- a shared create/edit form
- HTMX-enhanced filtering, deletion, and saving
You should end up with a result similar to this:
If you want the full boilerplate, the complete source is here:

Top comments (0)