Paginating Sanity Content with GROQ Without Over-Fetching Your Dataset
GROQ paginates with an inclusive slice operator applied after filtering and ordering. Here's the query shape, a working route handler, and the offset pitfalls to check before shipping.
17 May 2026, 10:51 UTC

Your Sanity-backed blog works fine with 30 posts. Then marketing publishes 400, and the page that fetches *[_type == "post"] starts timing out serverless functions and shipping megabytes of JSON to render ten titles. The fix is not a bigger timeout — it's slicing the query and projecting only what the page actually renders.
The takeaway up front: GROQ paginates with a plain slice operator ([start...end]) applied after filtering and ordering, and the two mistakes that hurt people are fetching unprojected documents and sorting on a non-deterministic field. Both are easy to avoid once you see the query shape.
How GROQ slicing actually works
GROQ evaluates a pipeline: filter (*[_type == "post"]), then order(), then the slice, then the projection. Order matters. If you slice before you sort, you get an arbitrary window of documents. GROQ's pipe syntax makes the order explicit:
*[_type == "post" && defined(slug.current)]
| order(publishedAt desc, _id asc)
[0...10] {
_id,
title,
"slug": slug.current,
publishedAt,
"excerpt": pt::text(body)[0...200]
}Three things to notice:
[0...10]is an inclusive range — it returns 10 items (indexes 0 through 9). For pagenwith page sizes, the slice is[n*s...(n+1)*s]. Off-by-one bugs here are the most common pagination mistake in GROQ.- The
order()includes_id ascas a tiebreaker. Two posts with the samepublishedAtwould otherwise swap positions between requests, causing items to appear on two pages or vanish entirely. - The projection keeps the payload small. Without it, every document ships its full
bodyportable text, image metadata, and references — often 10–50x more data than a listing page needs.
A worked example: a paginated route handler
Here's a Next.js App Router route handler that serves paginated posts. Run this server-side (route handlers execute on the server, so your Sanity read token, if you use one, never reaches the browser):
// app/api/posts/route.ts
import { createClient } from "next-sanity";
const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: "production",
apiVersion: "2024-01-01", // pin a date; don't use "v1" loosely
useCdn: true,
});
const PAGE_SIZE = 10;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);
const start = page * PAGE_SIZE;
const end = start + PAGE_SIZE;
const query = `{
"items": *[_type == "post"] | order(publishedAt desc, _id asc) [$start...$end] {
_id, title, "slug": slug.current, publishedAt
},
"total": count(*[_type == "post"])
}`;
const data = await client.fetch(query, { start, end });
return Response.json({
...data,
page,
pageCount: Math.ceil(data.total / PAGE_SIZE),
});
}Wrapping the slice and a count() in one query object gives you items and total in a single round trip. Parameters ($start, $end) are passed through the client's fetch options rather than string-interpolated, which avoids injection issues and keeps the query cacheable.
Expected result: GET /api/posts?page=2 returns items 20–29 plus a total. Verify locally by creating a known number of draft posts (say 25) and checking that page 0 has 10 items, page 2 has 5, and no _id repeats across pages. Don't assume it works — run it against your dataset, because client version and API version both affect behavior.
Offset pagination's real limits
Offset/limit slicing is simple, but it has two costs worth knowing:
- Deep pages get slower. Slicing
[10000...10010]still requires the query engine to walk past 10,000 documents. For most content sites this never matters; for large archives or API consumers paging through everything, it does. - Shifting content. If an editor publishes a post while someone is paging, offsets shift and items can be skipped or duplicated. For editorial content this is usually acceptable; for feeds that must be stable, consider cursor-style pagination keyed on the sort field (e.g.,
publishedAt < $lastSeen) instead of offsets.
Also note that very large page sizes can hit response-size or execution-time limits, which vary by plan and API version. Keep page sizes modest (10–100) and check Sanity's current docs for the limits that apply to your plan.
When GROQ feels verbose
For pure pagination, GROQ is more verbose than a typical GraphQL query — you're writing the filter, order, slice, and projection by hand every time. The trade-off is that the projection language is genuinely powerful: the pt::text(body)[0...200] excerpt above, reference expansion with author->name, and computed fields would each be resolver work in GraphQL. If your listing needs shaped content rather than raw documents, GROQ earns its syntax. If you only ever need flat lists of IDs and titles, Sanity's GraphQL endpoint may be the terser fit.
Check it before you ship it
Before deploying, run three checks against your real dataset: confirm the slice bounds are inclusive (page boundaries don't overlap), confirm the sort is deterministic (page through the full set and assert no duplicate _ids), and inspect the response payload size (it should be kilobytes, not megabytes — if not, tighten the projection). Pin your apiVersion so a Sanity-side change doesn't silently alter behavior, and re-verify pagination whenever you upgrade the client library.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.