Designing and Implementing Custom Offset-Based Pagination with Go and HTMX
Photo by Roman Trifonov on Unsplash
This article is based on a full stack demo application (Stock Price Index) I built while learning HTMX and Go. I wrote about my experience building the app here.
Prerequisite
The only prerequisite is that you understand the syntax and semantics of the Go programming language, HTML and CSS.
Project Set Up
The project set up is an adaptation of this very helpful guide.
For the sake of this guide, we will initialize our project using the command below. However, you can choose to initialize yours with a different name.
go mod init demo
Ensure you have these binaries installed on your local machine. Air and Make
In order to prevent this article from being unnecessarily long, we have to copy a few files from this repo. They should all be at the root level of your repo.
data/table.csv
air.toml
Makefile
app.env
Once that is done, run this command to download the only dependency we need.
go get github.com/a-h/templ
The Problem
Web applications often have a need to display a list of items to their users. In most cases, the dataset to be listed on the web page is often large and cannot be rendered all at once due to its performance implication.
Pagination is a technique used by web applications to partition this large dataset into manageable chunks that are rendered on request by the user. This significantly improves the performance and user experience of the app.
Offset-based pagination is one of 5 types of pagination. In most cases, offset-based pagination is handled using the OFFSET
and LIMIT
keywords in SQL queries while interacting with a relational database or an equivalent of that with a NoSQL database.
However, in this article, we are not going to use a database. We are going to load up a CSV file in-memory when the application starts up and then query for individual pages from the application memory.
The Solution
First, we need to establish a common vocabulary.
- Slide: A window showing a limited amount of partitions/pages in the dataset at a time.
- Pages Per Slide: The default amount of partitions/pages shown per slide.
- Default Page Size: The default amount of rows rendered per page.
- Rows: A slice (in Go speak) or a list of daily stock price data loaded up in memory on start up.
- Number of Pages: The total number of partitions for the stock price dataset.
- Page Offset: The index of the first row in the current partition/page.
- Slide Offset: The index of the first row in the current slide.
- Current Slide: The current window of pages.
- Number Of Slides: The total number of windows of pages.
When the CSV data is loaded in memory, it will be stored in a slice/list of rows. We will retrieve each partition/page requested using the slicing syntax in Go with the indexes, page offset and default page size.
Next, the code. We will start with the model of the application state. At all times, we will have access to the dataset in memory.
// model/model.go
package model
type Pagination struct {
NoOfPages int
PageOffset int
NoOfSlides int
SlideOffset int
CurrentSlide int
}
// we want to disable the next arrow button if there is no next slide
func (p *Pagination) HasNextSlide() bool {
return p.CurrentSlide+1 <= p.NoOfSlides
}
// we want to disable the previous arrow button if there is no previous slide
func (p *Pagination) HasPrevSlide() bool {
return p.CurrentSlide > 1
}
// the model that represents each row loaded up in memory on app start up
type Row struct {
Date string
Open string
High string
Low string
Close string
Volume string
AdjClose string
}
type Rows = []Row
type State struct {
Rows Rows
Pagination *Pagination
}
Next, we need to define some utility functions that will help us generate the statistics we need for the pagination. Here, we define the constants that can be tweaked to suit your need and we calculate other items in the Pagination
struct.
// util/util.go
package util
import (
"encoding/csv"
"fmt"
"log"
"net/http"
"os"
"strconv"
"demo/model"
)
const DEFAULT_PAGE_SIZE = 15
const PAGES_PER_SLIDE = 10
// LoadAppState loads the csv data in memory and transforms it to suit `model.State`
func LoadAppState() model.State {
stocks, err := readCsv("data/table.csv")
if err != nil {
log.Fatal(err)
}
state := model.State{
Rows: parseStocks(&stocks),
}
return state
}
// readCsv returns a slice containing slices of raw stock data
func readCsv(filename string) ([][]string, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
rows, err := csv.NewReader(f).ReadAll()
if err != nil {
return nil, err
}
return rows[1:], nil
}
// parseStocks parses the raw data read into memory and transforms it to suit `model.Rows`
func parseStocks(stocks *[][]string) (rows model.Rows) {
for _, stock := range *stocks {
open, _ := strconv.ParseFloat(stock[1], 64)
high, _ := strconv.ParseFloat(stock[2], 64)
low, _ := strconv.ParseFloat(stock[3], 64)
close, _ := strconv.ParseFloat(stock[4], 64)
adj, _ := strconv.ParseFloat(stock[6], 64)
rows = append(rows, model.Row{
Date: stock[0],
Open: fmt.Sprintf("%.2f", open),
High: fmt.Sprintf("%.2f", high),
Low: fmt.Sprintf("%.2f", low),
Close: fmt.Sprintf("%.2f", close),
Volume: stock[5],
AdjClose: fmt.Sprintf("%.2f", adj),
})
}
return
}
// Paginate generates the `model.Pagination` data and returns a pointer to it
func Paginate(r *http.Request, size int, rowLength int) *model.Pagination {
pg := &model.Pagination{}
p, err := strconv.Atoi(r.FormValue("page"))
if err != nil {
p = 1
}
po := generatePageOffset(p, size)
nop := generateNumberOfPages(rowLength, size)
if rowLength < size {
return &model.Pagination{
CurrentSlide: 1,
PageOffset: po,
NoOfSlides: 1,
SlideOffset: 1,
NoOfPages: nop,
}
}
var s int
if nop > PAGES_PER_SLIDE {
s = PAGES_PER_SLIDE
} else {
s = nop
}
nos := calculateTotalPagesOrSlides(nop, s)
pg = generateSlideStats(pg)
pg.NoOfSlides = nos
pg.NoOfPages = nop
pg.PageOffset = po
return pg
}
// generates the total number of partition/pages from the length of rows and DEFAULT_PAGE_SIZE
func generateNumberOfPages(rl int, size int) (nop int) {
if rl > DEFAULT_PAGE_SIZE {
nop = calculateTotalPagesOrSlides(rl, size)
} else {
nop = 1
}
return
}
func generatePageOffset(page int, size int) int {
return (page - 1) * size
}
// generates the total number of partition/pages when the length of rows > DEFAULT_PAGE_SIZE.
// It also generates the total number of sildes given the number of available pages and page per size
func calculateTotalPagesOrSlides(total, size int) (p int) {
var r int
if total >= size {
r = total % size
p = total / size
} else {
r = 0
p = total
}
if r > 0 {
return p + 1
} else {
return p
}
}
func generateSlideStats(p *model.Pagination) *model.Pagination {
if p.CurrentSlide == 0 {
p.CurrentSlide = 1
}
return setSlideOffset(p)
}
func setSlideOffset(p *model.Pagination) *model.Pagination {
if p.CurrentSlide == 1 {
p.SlideOffset = p.CurrentSlide
} else {
p.SlideOffset = (PAGES_PER_SLIDE * (p.CurrentSlide - 1)) + 1
}
return p
}
Now that we have what we need to load the csv data in memory on start up, and effect the pagination per request, we need to define the http handlers that will handle each request.
// handler/handler.go
package handler
import (
"net/http"
"demo/components"
"demo/model"
"demo/util"
)
type Handler struct {
state *model.State
}
func New(state *model.State) *Handler {
return &Handler{
state: state,
}
}
// handles the initial request for the app
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
l := len(h.state.Rows)
h.state.Pagination = util.Paginate(r, util.DEFAULT_PAGE_SIZE, l)
s := h.state
p := h.state.Pagination
// renders the page with the first 15 rows in the slice of stock data
components.Page(
s.Rows[p.PageOffset:(p.PageOffset+util.DEFAULT_PAGE_SIZE)], p,
).Render(r.Context(), w)
}
// handles the request for each page
func (h *Handler) Table(w http.ResponseWriter, r *http.Request) {
p := util.Paginate(r, util.DEFAULT_PAGE_SIZE, len(h.state.Rows))
rr := len(h.state.Rows) - p.PageOffset
// renders the remaining rows if less than the default number of rows to be rendered at a time,
// else renders the default number of rows to be rendered at a time (15 in this case)
if rr > util.DEFAULT_PAGE_SIZE {
components.Table(
h.state.Rows[p.PageOffset:(p.PageOffset+util.DEFAULT_PAGE_SIZE)],
).Render(r.Context(), w)
} else {
components.Table(h.state.Rows[p.PageOffset:(p.PageOffset+rr)]).Render(r.Context(), w)
}
}
// handles the request for the next slide if any
func (h *Handler) NextSlide(w http.ResponseWriter, r *http.Request) {
p := h.state.Pagination
if p.HasNextSlide() {
p.CurrentSlide = p.CurrentSlide + 1
p.SlideOffset = p.SlideOffset + util.PAGES_PER_SLIDE
}
components.Pagination(p).Render(r.Context(), w)
}
// handles the request for the previous slide if any
func (h *Handler) PrevSlide(w http.ResponseWriter, r *http.Request) {
p := h.state.Pagination
if p.HasPrevSlide() {
p.CurrentSlide = p.CurrentSlide - 1
p.SlideOffset = p.SlideOffset - util.PAGES_PER_SLIDE
}
components.Pagination(p).Render(r.Context(), w)
}
The components package is where we place the view code. In the project linked above, we have other functionalities like search and a form modal but we are focusing on just the pagination in this article. Thus, the view code below.
// components/pagination.templ
package components
import (
"fmt"
"demo/model"
"demo/util"
"strconv"
)
templ Pagination(p *model.Pagination) {
<nav class="pagination">
<div class="pagination-content">
// on click of this button, HTMX makes a request to the `PrevSlide` endpoint,
// the response from the server swaps the current slide (`nav` element) using the class `.pagination`
// with the new slide
<button
hx-get="/slide/prev"
hx-trigger="click"
hx-target=".pagination"
hx-swap="outerHTML"
if !p.HasPrevSlide() {
disabled
}
class="has-next-page"
>
«
</button>
@PaginationSlide(p)
<button
hx-get="/slide/next"
hx-trigger="click"
hx-target=".pagination"
hx-swap="outerHTML"
if !p.HasNextSlide() {
disabled
}
class="has-previous-page"
>
»
</button>
</div>
</nav>
}
templ PaginationSlide(p *model.Pagination) {
<ul class="pagination-slide">
// renders the remaining pages if less than the default number of pages to be rendered at a time,
// else renders the default number of pages to be rendered at a time (10 in this case)
for i := p.SlideOffset; i <= p.NoOfPages && i < p.SlideOffset+util.PAGES_PER_SLIDE; i++ {
<a href="#" id="page">
// on click of this element, HTMX makes a request to the `Table` endpoint,
// the response from the server swaps the element with class `.table`
// with the new row items of the new page
<li
hx-trigger="click"
hx-get={ fmt.Sprintf("/table?page=%d", i) }
hx-target=".table"
hx-swap="outerHTML"
hx-include="#search"
class="page"
>
{ strconv.Itoa(i) }
</li>
</a>
}
</ul>
}
// components/table.templ
package components
import "demo/model"
templ Table(rows []model.Row) {
<div class="table">
<div class="title row">
<div class="row-item">Date</div>
<div class="row-item">Open</div>
<div class="row-item">High</div>
<div class="row-item">Low</div>
<div class="row-item">Close</div>
<div class="row-item">Volume</div>
<div class="row-item">Adjacent Close</div>
</div>
<div id="rows" class="rows">
for _, row := range rows {
@TableRow(row)
}
</div>
</div>
}
templ TableRow(row model.Row) {
<div class="row">
<div class="row-item date">{ row.Date }</div>
<div class="row-item open">{ row.Open }</div>
<div class="row-item high">{ row.High }</div>
<div class="row-item low">{ row.Low }</div>
<div class="row-item close">{ row.Close }</div>
<div class="row-item volume">{ row.Volume }</div>
<div class="row-item adjClose">{ row.AdjClose }</div>
</div>
}
// components/page.templ
package components
import "demo/model"
templ Page(rows model.Rows, p *model.Pagination) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>SP Index</title>
<link rel="stylesheet" href="/static/css/style.css"/>
// import HTMX via CDN link
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous">
</script>
</head>
<body class="container body">
@Table(rows)
@Pagination(p)
</body>
</html>
}
Next, we need to set up the application server and router. We do this using the net/http
standard library in Go.
// main.go
package main
import (
"fmt"
"log"
"net/http"
"demo/handler"
"demo/model"
"demo/public"
"demo/util"
)
var state model.State
func init() {
state = util.LoadAppState()
}
func main() {
h := handler.New(&state)
http.Handle("GET /static/", http.FileServer(http.FS(public.StaticFiles)))
http.HandleFunc("GET /", h.Index)
http.HandleFunc("GET /table", h.Table)
http.HandleFunc("GET /slide/next", h.NextSlide)
http.HandleFunc("GET /slide/prev", h.PrevSlide)
fmt.Println("Listening on port 8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Lastly, we need to serve the public assets that has our css styles. In Go, we can easily serve static files with the following code:
// public/embed.go
package public
import "embed"
//go:embed static/*
var StaticFiles embed.FS
All static files in the static folder will be served by our application server when the browser request for them.
/* public/static/css/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: Arial, Helvetica, sans-serif;
}
.container {
padding: 1.5rem 5rem;
}
.body {
background-color: white;
position: relative;
}
.table {
width: 100%;
box-sizing: border-box;
text-align: center;
height: 100%;
}
.title {
background-color: grey;
color: white;
padding: 1rem;
}
.rows {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row {
display: flex;
flex: 1 1 10%;
}
.row-item {
flex: 1 1 10%;
padding: 0.5rem;
}
.date {
background-color: azure;
}
.open {
background-color: lightgrey;
}
.high {
background-color: lightgreen;
}
.low {
background-color: lightcoral;
}
.close {
background-color: bisque;
}
.volume {
background-color: lavender;
}
.adjClose {
background-color: aquamarine;
}
nav.pagination {
display: flex;
justify-content: center;
width: 100%;
padding: 1rem 0;
margin: 1rem;
}
nav > .pagination-content {
display: flex;
justify-content: space-between;
}
.pagination-slide > a {
display: inline-block;
text-decoration: none;
color: black;
}
.pagination-content > .pagination-slide {
display: flex;
gap: 0.2rem;
list-style-type: none;
}
.has-next-page,
.has-previous-page {
background-color: transparent;
border: none;
font-size: 1rem;
color: steelblue;
cursor: pointer;
margin: 0 0.5rem;
}
.has-next-page:disabled,
.has-previous-page:disabled {
color: lightgray;
cursor: not-allowed;
}
li.page {
display: inline-block;
padding: 0.5rem;
background-color: lightgrey;
border-radius: 3px;
margin: 0 0.3rem;
cursor: pointer;
}
li.page:hover {
background-color: darkgray;
}
@media screen and (max-width: 920px) {
.container {
padding: 0 2rem;
}
.table {
padding: 1rem;
width: 100%;
}
.row-item {
min-width: 1rem;
}
}
@media screen and (max-width: 450px) {
.container {
padding: 0 0.8rem;
}
.table {
min-width: 1200px;
}
}
To run the app, run this command from the root of your repo on your terminal
make run
Go to the browser and enter localhost:8081
on the search bar to interact with the app. You will discover how snappy the responses are, thanks to HTMX and Go.
Although this is a custom solution, I hope you found it enlightening. Thanks for reading.