Recipes
Paginated lists
This recipe shows how to build a complete list endpoint with pagination, sorting, filtering, bulk actions, and CSV export — the pattern used by every admin module in the standalone app. For advanced filter patterns — multi-column search, OR conditions, subquery filters, and custom LIKE — see Search & filtering.
The list handler
A typical list endpoint combines four framework helpers:
http.ParsePagination— extracts and validateslimit/offsetquery paramshttp.QueryParamSort— validatessort/orderquery params against a whitelistsqlite.SelectBuilder— builds the filtered, sorted, paginated querysqlite.CountFrom— derives a COUNT query from the SelectBuilder (no duplicated WHERE logic)
func listHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
pg := http.ParsePagination(r, 50, 100)
col, dir := http.QueryParamSort(r,
[]string{"id", "name", "email", "created_at"},
"id", "DESC",
)
selectQ := sqlite.Select("id", "name", "email", "created_at").
From("users").
Where("deleted_at IS NULL").
WhereSearch(r.URL.Query().Get("search"), "name", "email").
OrderBy(col, dir).
Limit(pg.Limit).
Offset(pg.Offset)
// Apply optional filters from query params
if role := r.URL.Query().Get("role"); role != "" {
selectQ.Where("role = ?", role)
}
// COUNT reuses table + WHERE from selectQ
countQ := sqlite.CountFrom(selectQ)
// Execute the SELECT
sql, args := selectQ.Build()
rows, err := db.Query(sql, args...)
if err != nil {
http.WriteError(w, http.StatusInternalServerError, "failed to list users")
return
}
defer rows.Close()
var users []map[string]any
for rows.Next() {
var id, createdAt int64
var name, email string
if err := rows.Scan(&id, &name, &email, &createdAt); err != nil {
http.WriteError(w, http.StatusInternalServerError, "failed to scan user")
return
}
users = append(users, map[string]any{
"id": id, "name": name, "email": email, "created_at": createdAt,
})
}
// Execute the COUNT
sql, args = countQ.Build()
var total int
if err := db.QueryRow(sql, args...).Scan(&total); err != nil {
http.WriteError(w, http.StatusInternalServerError, "failed to count users")
return
}
http.PaginatedResponse(w, "users", users, total)
}
}
The client requests: GET /api/admin/users?search=alice&sort=name&order=asc&limit=20&offset=40
Response:
{
"users": [
{"id": 42, "name": "Alice", "email": "alice@example.com", "created_at": 1710892800}
],
"total": 1
}
Extracting the query builder
When both the list handler and export handler need the same filters, extract the builder into a shared function:
func buildUserSelect(r *http.Request) *sqlite.SelectBuilder {
selectQ := sqlite.Select("id", "name", "email", "role", "created_at").
From("users").
Where("deleted_at IS NULL").
WhereSearch(r.URL.Query().Get("search"), "name", "email")
if role := r.URL.Query().Get("role"); role != "" {
selectQ.Where("role = ?", role)
}
return selectQ
}
Now both handlers reuse the same filter logic:
// List handler
func listHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
pg := http.ParsePagination(r, 50, 100)
col, dir := http.QueryParamSort(r, allowedCols, "id", "DESC")
selectQ := buildUserSelect(r).OrderBy(col, dir).Limit(pg.Limit).Offset(pg.Offset)
countQ := sqlite.CountFrom(selectQ)
// ... execute and respond ...
}
}
// Export handler — same filters, no pagination
func exportHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
col, dir := http.QueryParamSort(r, allowedCols, "id", "DESC")
selectQ := buildUserSelect(r).OrderBy(col, dir)
// ... execute and write CSV ...
}
}
CSV export
Export the filtered data as a downloadable CSV file. The export handler reuses the same query builder as the list handler but without pagination — all matching rows are exported:
func exportHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
col, dir := http.QueryParamSort(r,
[]string{"id", "name", "email", "created_at"},
"id", "DESC",
)
selectQ := buildUserSelect(r).OrderBy(col, dir)
sql, args := selectQ.Build()
rows, err := db.Query(sql, args...)
if err != nil {
http.WriteError(w, http.StatusInternalServerError, "failed to export")
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=users-%s.csv",
time.Now().UTC().Format("20060102")))
cw := csv.NewWriter(w)
_ = cw.Write([]string{"ID", "Name", "Email", "Role", "Created At"})
for rows.Next() {
var id int64
var name, email, role string
var createdAt int64
if err := rows.Scan(&id, &name, &email, &role, &createdAt); err != nil {
break
}
_ = cw.Write([]string{
strconv.FormatInt(id, 10),
name, email, role,
time.Unix(createdAt, 0).UTC().Format(time.RFC3339),
})
}
cw.Flush()
}
}
Register on a separate route:
group.HandleFunc("GET /users", listHandler(db))
group.HandleFunc("GET /users/export", exportHandler(db))
Bulk actions
Bulk actions receive an array of IDs in the JSON body. Validate with http.CheckBulkIDs, then use the query builder with WhereIn:
func bulkDeleteHandler(db *sqlite.DB) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
IDs []int64 `json:"ids"`
}
if err := http.ReadJSON(r, &req); err != nil {
http.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
if !http.CheckBulkIDs(w, req.IDs, 100) {
return
}
now := time.Now().UTC().Format(time.RFC3339)
ids := make([]any, len(req.IDs))
for i, id := range req.IDs {
ids[i] = id
}
query, args := sqlite.Update("users").
Set("deleted_at", now).
Set("updated_at", now).
Where("deleted_at IS NULL").
WhereIn("id", ids...).
Build()
result, err := db.Exec(query, args...)
if err != nil {
http.WriteError(w, http.StatusInternalServerError, "failed to delete users")
return
}
http.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"affected": result.RowsAffected,
})
}
}
Register on a POST route (bulk operations carry a JSON body):
group.HandleFunc("POST /users/bulk-delete", bulkDeleteHandler(db))
For the complete set of bulk patterns — hard-delete with FK cleanup, bulk state changes, loop-based service calls, batch upserts, audit logging, and webhook dispatch — see the Bulk operations recipe.
Putting it all together
Register all endpoints in your module:
func Register(group *http.Group, db *sqlite.DB) {
group.HandleFunc("GET /users", listHandler(db))
group.HandleFunc("GET /users/export", exportHandler(db))
group.HandleFunc("POST /users/bulk-delete", bulkDeleteHandler(db))
group.HandleFunc("GET /users/{id}", getHandler(db))
group.HandleFunc("POST /users", createHandler(db))
group.HandleFunc("PUT /users/{id}", updateHandler(db))
group.HandleFunc("DELETE /users/{id}", deleteHandler(db))
}
The list, export, and bulk delete handlers share the same query builder function, keeping filter logic in one place. The list handler adds pagination and sorting; the export handler adds sorting without pagination; the bulk delete handler operates on explicit IDs.