Paginating document archive pages with custom routes in Sanity

3 replies
Last updated: Feb 11, 2021
looking for help with setting up a “page of posts” … its a document type archive page, but it exists as a page itself (in order to get hero details, etc) and then i want to query for all the documents of the type… the area i’m struggling with is paginating it, so something like
/resources/page/2
etc
AI Update

For setting up a paginated "page of posts" where the page itself is a document (with hero details, etc.) and you want to paginate through posts at URLs like /resources/page/2, here's how to approach it:

The Setup

You'll have two document types:

  1. A page document (e.g., resources) with your hero details and metadata
  2. Multiple post documents (e.g., article, blogPost, etc.) that you want to paginate

Based on the official pagination guide, avoid using array slicing like [100...200] for pagination as it becomes inefficient with large datasets. Instead, use filter-based pagination.

Filter-Based Pagination

For the first page (/resources or /resources/page/1):

*[_type == "article"] | order(_id) [0...100] {
  _id, title, body, slug
}

For subsequent pages (/resources/page/2, etc.), use filtering with the last document's ID:

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

Sorting by Other Fields (like publishedAt)

If you want to sort by a non-unique field like publishedAt, you need a tiebreaker to avoid skipping documents:

*[_type == "article" && (
  publishedAt > $lastPublishedAt
  || (publishedAt == $lastPublishedAt && _id > $lastId)
)] | order(publishedAt) [0...100] {
  _id, title, body, publishedAt
}

Implementation Example

Here's how you might structure your Next.js route:

// app/resources/[[...page]]/page.tsx
export default async function ResourcesPage({ params }) {
  const pageNum = params.page?.[1] ? parseInt(params.page[1]) : 1;
  
  // Fetch the page document for hero details
  const pageData = await client.fetch(`
    *[_type == "resourcesPage"][0] {
      title, hero, description
    }
  `);
  
  // For pagination, you'll need to track lastId
  // This is tricky with page numbers - see below
  const posts = await client.fetch(`
    *[_type == "article"] | order(_id) [${(pageNum - 1) * 100}...${pageNum * 100}] {
      _id, title, slug
    }
  `);
  
  return (
    <div>
      <Hero {...pageData.hero} />
      <PostList posts={posts} currentPage={pageNum} />
    </div>
  );
}

The Page Number Challenge

The filter-based approach doesn't support random access to page numbers easily. To jump to page 5, you'd need to know the lastId from page 4. You have two options:

Option 1: Sequential Navigation Only (most performant)

  • Only show "Previous" and "Next" buttons
  • Store the lastId in the URL as a query parameter: /resources?lastId=abc123
  • This is the most efficient approach

Option 2: Support Page Numbers (less performant at scale)

  • Accept that higher page numbers will be slower
  • Use array slicing for simplicity: [${(page-1)*100}...${page*100}]
  • The pagination guide notes this becomes slower linearly with page number, but may be acceptable for your use case

Getting Total Count

To show total pages, use:

count(*[_type == "article"])

This should be relatively fast but watch out with very large datasets.

My Recommendation

For a public-facing "resources" page, I'd suggest Option 2 with array slicing despite the performance tradeoff, because:

  • Users expect numbered pagination on content archives
  • Most visitors won't navigate past the first few pages anyway
  • It's much simpler to implement
  • You can always optimize later if needed

If you have thousands of posts and notice performance issues, then switch to sequential navigation with filter-based pagination.

Show original thread
3 replies
I think this should help you, it’s how we generate a paginated “category” section for our site:
exports.createBlogPostPagesSanity = async ({ graphql, actions, reporter }) => {
  const { createPage } = actions;

  const blogPostTemplateSanity = path.resolve(`src/templates/blog-post.js`);

  const newsIndexTemplate = path.resolve(`src/templates/news-index.js`);

  // From <https://github.com/sanity-io/example-company-website-gatsby-sanity-combo/blob/master/web/gatsby-node.js>
  const result = await graphql(`
    query {
      allSanityPost(filter: { slug: { current: { ne: null } } }) {
        nodes {
          id
          route
          category {
            name
          }
        }
      }
    }
  `);

  if (result.errors) throw result.errors;

  const sanityPosts = result.data.allSanityPost.nodes || [];

  <http://reporter.info|reporter.info>(`Processing ${sanityPosts.length} blog posts from <http://Sanity.io|Sanity.io>`);

  sanityPosts.forEach((post) => {
    createPage({
      path: post.route,
      component: blogPostTemplateSanity,
      context: {
        id: post.id,
      },
    });
  });

  const postsPerPage = 20;
  const categories = ['national', 'state', 'resistbot'];
  for (let category of categories) {
    const categoryPosts = sanityPosts.filter(
      (post) => post.category.name === category
    );
    const numPages = Math.ceil(categoryPosts.length / postsPerPage) || 1;
    Array.from({
      length: numPages,
    }).forEach((_, i) => {
      createPage({
        path:
          i === 0
            ? `${BASE_URL_BLOG}/${category}`
            : `${BASE_URL_BLOG}/${category}/${i + 1}`,
        component: newsIndexTemplate,
        context: {
          limit: postsPerPage,
          skip: i * postsPerPage,
          numPages,
          currentPage: i + 1,
          category,
        },
      });
    });
  }
};
thanks! i’ll check this out

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?