DEV Community

Cover image for Go Admin Dashboard for E-Commerce with HTMX, Templ UI, and GORM - Part 2
ColaFanta
ColaFanta

Posted on

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

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"},
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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",
                })
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)