package api
import "fmt"
type Resource struct {
Type string `json:"type"`
ID string `json:"id"`
Attributes map[string]interface{} `json:"attributes"`
}
type Links struct {
Self string `json:"self"`
First string `json:"first,omitempty"`
Prev string `json:"prev,omitempty"`
Next string `json:"next,omitempty"`
}
type Document struct {
Data []Resource `json:"data"`
Links Links `json:"links"`
Meta map[string]interface{} `json:"meta"`
}
func PageLinks(base string, page, size, total int, hasNext bool) Links {
link := func(n int) string {
return fmt.Sprintf("%s?page[number]=%d&page[size]=%d", base, n, size)
}
l := Links{Self: link(page), First: link(1)}
if page > 1 {
l.Prev = link(page - 1)
}
if hasNext {
l.Next = link(page + 1)
}
return l
}
package api
import (
"context"
"database/sql"
)
type Article struct {
ID string
Title string
Body string
}
type ArticleRepo struct {
DB *sql.DB
}
func (r *ArticleRepo) ListArticles(ctx context.Context, page, pageSize int) (items []Article, total int, hasNext bool, err error) {
offset := (page - 1) * pageSize
rows, err := r.DB.QueryContext(ctx,
`SELECT id, title, body FROM articles ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
pageSize+1, offset)
if err != nil {
return nil, 0, false, err
}
defer rows.Close()
for rows.Next() {
var a Article
if err := rows.Scan(&a.ID, &a.Title, &a.Body); err != nil {
return nil, 0, false, err
}
items = append(items, a)
}
if err := rows.Err(); err != nil {
return nil, 0, false, err
}
if len(items) > pageSize {
hasNext = true
items = items[:pageSize]
}
if err := r.DB.QueryRowContext(ctx, `SELECT count(*) FROM articles`).Scan(&total); err != nil {
return nil, 0, false, err
}
return items, total, hasNext, nil
}
package api
import (
"encoding/json"
"net/http"
"strconv"
)
const maxPageSize = 100
type ArticlesHandler struct {
Repo *ArticleRepo
}
func parsePage(r *http.Request) (page, size int) {
page, _ = strconv.Atoi(r.URL.Query().Get("page[number]"))
size, _ = strconv.Atoi(r.URL.Query().Get("page[size]"))
if page < 1 {
page = 1
}
if size < 1 {
size = 25
}
if size > maxPageSize {
size = maxPageSize
}
return page, size
}
func (h *ArticlesHandler) List(w http.ResponseWriter, r *http.Request) {
page, size := parsePage(r)
items, total, hasNext, err := h.Repo.ListArticles(r.Context(), page, size)
if err != nil {
http.Error(w, `{"errors":[{"status":"500","title":"Internal Server Error"}]}`, http.StatusInternalServerError)
return
}
data := make([]Resource, 0, len(items))
for _, a := range items {
data = append(data, Resource{
Type: "articles",
ID: a.ID,
Attributes: map[string]interface{}{
"title": a.Title,
"body": a.Body,
},
})
}
doc := Document{
Data: data,
Links: PageLinks("/articles", page, size, total, hasNext),
Meta: map[string]interface{}{"total": total, "page": page},
}
w.Header().Set("Content-Type", "application/vnd.api+json")
json.NewEncoder(w).Encode(doc)
}
This snippet shows how a Go service builds a paginated collection response that conforms to the JSON:API specification, split across a serializer, a repository query layer, and the HTTP handler that wires them together.
In jsonapi.go, the response envelope is modeled with the shapes JSON:API mandates: a top-level Document carrying a data array of Resource objects, a links object, and a meta object. Each Resource uses a type/id/attributes structure rather than a flat object, which is what distinguishes JSON:API from an ad-hoc REST payload. The PageLinks helper computes self, first, prev, and next URLs from the current page state, deliberately omitting prev on the first page and next on the last so clients can drive navigation purely from links (HATEOAS) rather than constructing query strings themselves.
repository.go handles the data side. ListArticles implements keyset-friendly offset pagination but, crucially, fetches limit+1 rows via pageSize+1. That extra row is a cheap way to know whether a following page exists without issuing a separate COUNT(*), which avoids a second scan on large tables. The surplus row is trimmed before returning, and hasNext is reported alongside the slice. A total count is still queried for meta, since JSON:API clients commonly render it, but the has-next signal is what powers the next link.
handler.go performs the HTTP work. parsePage reads page[number] and page[size] — the bracketed query parameters JSON:API prescribes — clamping size to a sane maximum so a client cannot request an unbounded page. The handler sets the application/vnd.api+json content type required by the spec, translates repository rows into Resource values, and assembles the final Document with links and meta. The number of trade-offs here is worth noting: offset pagination is simple and jump-to-page friendly but degrades on deep offsets, whereas cursor pagination scales better yet loses random access. This implementation favors simplicity and the familiar page-number UX. Reaching for this pattern makes sense when building a standards-compliant public API where predictable envelopes, discoverable navigation, and consistent error handling matter more than raw throughput.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
Share this code
Here's the card — post it anywhere.