Minimal content pagination for Sanity.io data

How I approach pagination through powerful & expressive GROQ queries

Here's the high-level view of how to do Sanity pagination with GROQ:

  1. define a filter for the documents you need to fetch
  2. limit them with query slices
  3. paginate by changing the number in the slices
  4. and keep tab of the total page count to properly display the pagination UI in the front-end
Note

This guide is focused on the data side of pagination. With the concepts here, you can build both a traditional pagination widget with next/prev links, as well as infinite scrolling feeds.

Querying paginated data

Now, let's tackle this in practice. Let's assume you have a feed of content, which you fetch like so:

*[
  // A multi-content feed
  _type in ["news", "thought", "poem", "event"] &&
  !(_id in path("drafts.**")) &&
  defined(slug.current) &&
  status == "published"
]
// Editor-picked & popular come first
| score(editorPick, popularity)
// Freshest and hottest first
| order(_score desc, _createdAt desc)

The first thing you need to do is to extract the query filter into a re-usable fragment so that you can use it consistently across the places we'll need it (see the pagination UI section below).

export const COLLECTION_FRAGMENT = /* groq */ `
*[
  _type in ["news", "thought", "poem", "event"] &&
  !(_id in path("drafts.**")) &&
  defined(slug.current) &&
  status == "published"
]
`;
Note

I'm intentionally using a complicated content model & query to show you this method works with any query complexity. This approach is the same as used in sanity.io/exchange, with all of its contribution types and moderation mechanisms.

As long as you have a way to get a specific collection of items, you have a way of paginating them.

With this, you can build the most obvious part of your query, the one that gets the actual data:

const ITEMS_PER_PAGE = 10;

const myData = await sanityClient.fetch(
  /* groq */ `{
  "items": ${COLLECTION_FRAGMENT}

    // === ORDERING THE COLLECTION ===
    // Editor-picked & popular come first
    | score(editorPick, popularity)
    // Freshest and hottest first
    | order(_score desc, _createdAt desc)
    
    // === SLICING THE COLLECTION ===
    [($pageIndex * ${ITEMS_PER_PAGE})...($pageIndex + 1) * ${ITEMS_PER_PAGE}]
    {
      ..., // your item data projection here
    }
  }`,
  // == Query parameters ==
  {
    // Let's get the first page
    pageIndex: 0,
  }
);

See how above we're simply appending the scoring & ordering operators after the filter fragment? It may be hard to grasp at first, but it helps to remember that GROQ runs sequentially and we can chain our operations.

In the snippet above we're already slicing up our collection to get only the amount defined by our ITEMS_PER_PAGE variable. Let's break that line down:

// === SLICING THE COLLECTION ===
const ITEMS_PER_PAGE = 10;

// Notice the difference between the GROQ variable ($pageIndex)
// and the javascript constant (${ITEMS_PER_PAGE})
const slice = `[($pageIndex * ${ITEMS_PER_PAGE})...($pageIndex + 1) * ${ITEMS_PER_PAGE}]`

// ITEMS_PER_PAGE is fixed/constant, so we could inject it in the query directly
const sliceWithParam = `[($pageIndex * 10)...($pageIndex + 1) * 10]`

// Now, let's add a parameter to the query
const myData = await sanityClient.fetch(
  "QUERY_HERE",
  // == Query parameters ==
  {
    pageIndex: 0,
  }
);

// Injecting the parameter in the query...
const sliceWithValue = `[(0 * 10)...(0 + 1) * 10]` // or [0...10], AKA the first 10 items

If the above isn't clear, going through my GROQ course's section on slices may help 😉

Now, all you need to do is change your pageIndex parameter and you'll get the proper data for each page ✨

Powering the pagination UI

In order to build a pagination widget or an infinite scrolling mechanism that users can interact with, you'll need to know how many pages there are available.

This is where our re-usable filter fragment comes in handy. We can create a pagination object with the current pageNumber and totalPageCount in our GROQ query:

const myData = await sanityClient.fetch(`{
  "items": {
    // Our previous item query here
  },
  "pagination": {
    "totalPageCount": count(${COLLECTION_FRAGMENT}._id) / ${ITEMS_PER_PAGE},
    "pageNumber": $pageIndex + 1,
  }
}`,
  // == Query parameters ==
  {
    // Let's get the first page
    pageIndex: 0,
  }
);

Now, you can buid any pagination UI you desire (you'll need to round totalPageCount, though)!

If you're designing the UI, do check out Smashing Magazine's article on designing better infinite scroll, which covers well the distinction and trade-offs between traditional pagination links and endless feeds.

Back/front-end example with SvelteKit

If you're still wondering how to implement the full flow, this SvelteKit implementation may be a good reference. I'm doing traditional pagination, but infinite scrolling would have a similar approach, except querying from an API endpoint on the client-side.

First, let's create a route under routes/page/[pageNumber].svelte:

<script lang="ts">
	import PaginationUI from '$lib/components/PaginationUI.svelte';
	import PostsGrid from '$lib/components/PostsGrid.svelte';
	import type { PaginationData, PostCardProps } from '$lib/types';

	export let posts: PostCardProps[] = [];
	export let pagination: PaginationData;
</script>

<h1 class="page-title">Posts</h1>

<PostsGrid {posts} />

<PaginationUI {pagination} />

To get the posts and pagination data this route depends on, we'll create a page endpoint at routes/page/[pageNumber].ts:

import { client } from '$lib/client';
import { PAGINATION_FRAGMENT, POST_LIST_QUERY, SETTINGS_QUERY } from '$lib/queries';

export const get = async ({ params }) => {
	const pageNumber = params.number ? Number(params.number) : 1;
	const data = await client.fetch(
		`{
		"posts": ${POST_LIST_QUERY},
		"settings": ${SETTINGS_QUERY},
		"pagination": ${PAGINATION_FRAGMENT}
	}`,
		{
			pageIndex: pageNumber - 1
		}
	);
	
	if (!data.posts?.length) {
	  return {
	    status: 404
	  }
	}

	return {
		body: {
			...data,
			pagination: {
				...(data.pagination || {}),
        // Assume totalPageCount is at least 1, and round it up
				totalPageCount: Math.ceil(data.pagination?.totalPageCount || 1)
			}
		}
	};
};

And, if you're wondering, here's my minimal, unstyled PaginationUI component:

<script lang="ts">
	import type { PaginationData } from '$lib/types';
	import { getPathFromSlug } from '$lib/urls';

	export let pagination: PaginationData;

	$: getPaginationHref = (pageNumber: number): string => {
		if (pageNumber <= 1) {
			return '/';
		}

		if (pageNumber >= pagination.totalPageCount) {
			return getPathFromSlug(`page/${pagination.totalPageCount}`);
		}
		return getPathFromSlug(`page/${pageNumber}`);
	};
</script>

<div>
	<a
		href={getPaginationHref(pagination.pageNumber - 1)}
		disabled={pagination.pageNumber - 1 <= 0}
		rel={pagination.pageNumber === 1 ? 'current' : undefined}
		class="nav-link"
	>
		Previous
	</a>
	<span>
		Page {pagination.pageNumber} of {pagination.totalPageCount}
	</span>
	<a
		href={getPaginationHref(pagination.pageNumber + 1)}
		disabled={pagination.pageNumber + 1 > pagination.totalPageCount}
		rel={pagination.pageNumber >= pagination.totalPageCount ? 'current' : undefined}
		class="nav-link"
	>
		Next
	</a>
</div>

SvelteKit bonus: parameter matching

SvelteKit recently landed support for parameter matching to verify if a given parameter value is valid for a given route or not.

params/pageNumber will ensure page numbers can only be positive integers:

import type { ParamMatcher } from '@sveltejs/kit';

export const match: ParamMatcher = (param) => {
	return /^\d+$/.test(param) && Number(param) > 1;
};

And with this we can update our route's filenames to be [number=pageNumber], so if page/im-not-a-number or page/-130 get requested SvelteKit will immediately return a 404.