Type something to search...
Pagination in Sanity With GROQ

Pagination in Sanity With GROQ

Pagination in Sanity with GROQ works in two ways. The simple way is slicing: sort the results, then take a range such as [0...10] for page one and [10...20] for page two, calculated from the page number. The scalable way is filter-based (cursor) pagination: sort by a field plus _id, remember the last item you returned, and fetch the next page with a filter like _id > $lastId. Slicing is fine for small collections, but Sanity recommends filtering for large ones, because a slice with a big offset still has to process every document before it.

Almost every content site needs pagination somewhere: a blog archive, a product listing, an infinite scroll feed or a batch script that exports content. The right technique depends on how large the collection is and how users navigate it. This guide explains slicing and filter-based pagination with working GROQ, how to get a total count for page numbers, how to handle sort fields that aren't unique, and how to build both a numbered archive and a "load more" feed with the Next.js App Router.

Slicing Basics

GROQ slices take a start and end index. Three dots exclude the end, two dots include it:

*[_type == "post"] | order(publishedAt desc) [0...10]   // first 10
*[_type == "post"] | order(publishedAt desc) [10...20]  // next 10

Always sort before you slice. Without order(), the order of results isn't something you should rely on, so the same page could return different documents on different requests.

Slices accept parameters, so one query serves every page:

*[_type == "post" && defined(slug.current)]
  | order(publishedAt desc, _id desc)
  [$start...$end] {
    _id, title, "slug": slug.current, publishedAt
  }
const pageSize = 10
const page = 3
const posts = await client.fetch(query, {
  start: (page - 1) * pageSize,
  end: page * pageSize,
})

Why Large Offsets Get Slow

GROQ can't jump straight to the 10,000th result. To return [10000...10010], the engine has to find, sort and skip the first 10,000 matching documents. Sanity's own documentation notes that cost grows roughly in line with the offset, and that very deep slices can take seconds.

TechniqueCost of deep pagesPage numbersStable when content changesBest for
SlicingGrows with offsetEasyNoSmall to medium collections
Filter-based (cursor)Roughly constantHardYesLarge collections, feeds, exports

There's a second problem with slicing: if a post is published while someone is reading page one, everything shifts down by one, and page two starts with a post they've already seen. Filter-based pagination avoids this because it asks for "items after this one" rather than "items at this position".

For a blog with a few hundred posts, slicing is perfectly fine. Use filters when collections reach thousands of documents, when you're building infinite scroll, or when you're processing a whole dataset.

Filter-Based Pagination

The idea is simple: sort by a unique field, return a page, then filter the next request to items that come after the last one.

Using _id

_id is unique, and datasets are stored in _id order, which makes it the cheapest field to paginate on:

// First page
*[_type == "article"] | order(_id) [0...100] { _id, title }

// Next pages
*[_type == "article" && _id > $lastId] | order(_id) [0...100] { _id, title }

This is ideal for scripts and exports, where the order doesn't matter to a reader:

// scripts/export-articles.ts
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: 'production',
  apiVersion: '2025-02-19',
  useCdn: false,
  token: process.env.SANITY_READ_TOKEN,
})

const query = `*[_type == "article" && _id > $lastId] | order(_id) [0...500] { _id, title }`

async function exportAll() {
  let lastId = ''
  const all: { _id: string; title: string }[] = []

  while (true) {
    const batch = await client.fetch<{ _id: string; title: string }[]>(query, { lastId })
    if (batch.length === 0) break
    all.push(...batch)
    lastId = batch[batch.length - 1]._id
  }

  console.log(`Exported ${all.length} articles`)
}

exportAll()

Starting with an empty string works because every ID sorts after it. Sanity's guidance is to keep batches to a few thousand documents at most, and smaller for very large documents.

Using a Non-Unique Field Such as a Date

Readers want posts sorted by date, not by ID. But dates aren't unique: two posts published at the same moment would confuse a simple publishedAt < $lastPublishedAt filter, and one of them could be skipped. Add _id as a tiebreaker in both the sort and the filter:

*[_type == "post" && (
  publishedAt < $lastPublishedAt
  || (publishedAt == $lastPublishedAt && _id < $lastId)
)] | order(publishedAt desc, _id desc) [0...10] {
  _id, title, "slug": slug.current, publishedAt
}

Because this is a descending sort, the comparisons use <. For ascending sorts, flip them to >. The rule is that the filter must match the sort exactly, field by field.

For the first page, either run a separate query without the cursor filter, or pass a cursor value that sorts before everything, such as a date far in the future.

Getting the Total Count

Numbered pagination needs a total so you can show "Page 3 of 12". Fetch it in the same request as the page:

{
  "posts": *[_type == "post" && defined(slug.current)]
    | order(publishedAt desc, _id desc) [$start...$end] {
      _id, title, "slug": slug.current, publishedAt
    },
  "total": count(*[_type == "post" && defined(slug.current)])
}

Keep the filter in both parts identical, or your page count won't match your results. count() is fast for typical collections but does more work on very large ones, which is another reason infinite scroll often skips the total altogether. The GROQ functions guide covers count() in more detail.

Numbered Pagination in Next.js

Here's a blog archive at /blog?page=2 using the App Router, with slicing and a total count:

// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

export const POSTS_PAGE_QUERY = defineQuery(`{
  "posts": *[_type == "post" && defined(slug.current)]
    | order(publishedAt desc, _id desc) [$start...$end] {
      _id, title, "slug": slug.current, publishedAt
    },
  "total": count(*[_type == "post" && defined(slug.current)])
}`)
// src/app/blog/page.tsx
import Link from 'next/link'
import { client } from '@/sanity/client'
import { POSTS_PAGE_QUERY } from '@/sanity/queries'

const PAGE_SIZE = 10

export default async function BlogArchive({
  searchParams,
}: {
  searchParams: Promise<{ page?: string }>
}) {
  const { page: pageParam } = await searchParams
  const page = Math.max(1, Number(pageParam) || 1)

  const { posts, total } = await client.fetch(
    POSTS_PAGE_QUERY,
    { start: (page - 1) * PAGE_SIZE, end: page * PAGE_SIZE },
    { next: { revalidate: 60 } },
  )
  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))

  return (
    <main>
      <ul>
        {posts.map((post) => (
          <li key={post._id}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>

      <nav aria-label="Pagination">
        {page > 1 && <Link href={`/blog?page=${page - 1}`}>Previous</Link>}
        <span>
          Page {page} of {totalPages}
        </span>
        {page < totalPages && <Link href={`/blog?page=${page + 1}`}>Next</Link>}
      </nav>
    </main>
  )
}

Clamp the page number so ?page=0 or ?page=abc can't produce a negative slice. If you'd rather have static URLs like /blog/page/2, the same query works in a dynamic route with generateStaticParams() returning one entry per page.

Load More With Cursor Pagination

For an infinite feed, return a cursor with each page and let the client ask for the next one. A Route Handler keeps the query on the server:

// src/app/api/posts/route.ts
import { NextResponse } from 'next/server'
import { client } from '@/sanity/client'

const FIRST_PAGE = `*[_type == "post" && defined(slug.current)]
  | order(publishedAt desc, _id desc) [0...10] { _id, title, "slug": slug.current, publishedAt }`

const NEXT_PAGE = `*[_type == "post" && defined(slug.current) && (
  publishedAt < $lastPublishedAt
  || (publishedAt == $lastPublishedAt && _id < $lastId)
)] | order(publishedAt desc, _id desc) [0...10] { _id, title, "slug": slug.current, publishedAt }`

type Post = { _id: string; title: string; slug: string; publishedAt: string }

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const lastId = searchParams.get('lastId')
  const lastPublishedAt = searchParams.get('lastPublishedAt')

  const posts =
    lastId && lastPublishedAt
      ? await client.fetch<Post[]>(NEXT_PAGE, { lastId, lastPublishedAt })
      : await client.fetch<Post[]>(FIRST_PAGE)

  const last = posts.at(-1)
  return NextResponse.json({
    posts,
    nextCursor: posts.length === 10 && last ? { lastId: last._id, lastPublishedAt: last.publishedAt } : null,
  })
}
// src/components/load-more-posts.tsx
'use client'

import { useState } from 'react'

type Post = { _id: string; title: string; slug: string; publishedAt: string }
type Cursor = { lastId: string; lastPublishedAt: string } | null

export function LoadMorePosts({ initial, initialCursor }: { initial: Post[]; initialCursor: Cursor }) {
  const [posts, setPosts] = useState(initial)
  const [cursor, setCursor] = useState(initialCursor)
  const [loading, setLoading] = useState(false)

  async function loadMore() {
    if (!cursor) return
    setLoading(true)
    const params = new URLSearchParams(cursor)
    const res = await fetch(`/api/posts?${params}`)
    const data: { posts: Post[]; nextCursor: Cursor } = await res.json()
    setPosts((current) => [...current, ...data.posts])
    setCursor(data.nextCursor)
    setLoading(false)
  }

  return (
    <>
      <ul>
        {posts.map((post) => (
          <li key={post._id}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
      {cursor && (
        <button onClick={loadMore} disabled={loading}>
          {loading ? 'Loading...' : 'Load more'}
        </button>
      )}
    </>
  )
}

Every request passes its cursor values as GROQ parameters, so nothing from the URL is concatenated into the query. Make sure publishedAt is required in your schema, or filter with defined(publishedAt), because a null cursor value breaks the comparison.

Common Pitfalls

  1. Slicing without sorting: results without order() aren't guaranteed to come back in a consistent order between requests.
  2. A sort without a tiebreaker: always add _id as the last sort key when the main key can repeat.
  3. Filter and sort out of sync: a descending sort needs < in the cursor filter, an ascending sort needs >.
  4. Different filters for count and page: keep them identical or the totals won't line up.
  5. Unbounded page numbers: validate and clamp page parameters before calculating offsets.
  6. Null sort values: documents missing the sort field can't be compared against a cursor, so require the field or filter it.

Test each variant in the Vision plugin with real parameter values before wiring it into your UI. For a refresher on sorting rules, see filtering and sorting with GROQ.


FAQ: Pagination With GROQ

Sort the results with order() and slice them, for example [0...10] for the first page and [10...20] for the second. Calculate the start and end from the page number and pass them as parameters.

GROQ has to find, sort and skip every result before the start index, so the cost grows with the offset. For a few hundred documents this doesn't matter, but deep pages in large collections can become slow.

Instead of asking for items at a position, you ask for items after the last one you returned, using a filter such as _id > $lastId on a sorted query. The cost stays roughly the same for every page, and results don't shift when new content is published.

Sort by the date and then by _id, and use a filter that compares the date first and falls back to _id when the dates are equal. This tiebreaker makes the order unique, so no documents are skipped or repeated.

Return a count alongside the page in the same query, using a top-level object with one key for the slice and one for count() with the same filter. Divide the total by your page size and round up.

Not easily, because a cursor only knows the item before the next page, not its position. Cursor pagination suits next and previous navigation, load more buttons and infinite scroll. If you need numbered pages, slicing is usually the practical choice.


Conclusion

GROQ gives you two pagination tools. Slicing with [$start...$end] after an order() is quick to build and works well for archives with page numbers and modest collections. Filter-based pagination, with a unique sort and a cursor filter on the last item, keeps every page fast and stable, which makes it the right choice for large datasets, infinite scroll and export scripts.

Whichever you choose, sort deterministically with _id as a tiebreaker, keep counts and pages on the same filter, and pass every value as a parameter. With those rules, your listing pages will stay correct and responsive as your content grows.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper