go 141 lines · 3 tabs

Serving Paginated JSON:API Collection Responses in Go

Shared by codesnips Aug 2026
3 tabs
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
}
3 files · go Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Serving Paginated JSON:API Collection Responses in Go — share card
Link copied