VOOZH about

URL: https://dev.to/colafanta/go-admin-dashboard-for-e-commerce-with-htmx-templ-ui-and-gorm-part-2-d6e

⇱ Go Admin Dashboard for E-Commerce with HTMX, Templ UI, and GORM - Part 2 - DEV Community


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 isEditing is false, the form behaves like a create page
  • when isEditing is true, 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:

👁 Product List

If you want the full boilerplate, the complete source is here:

https://github.com/ColaFanta/go-simple-admin-template