How to add Load More button to Sanity posts with pagination?

4 replies
Last updated: Jun 3, 2023
Hello, Everyone.
Please tell me how to connect together in different ways. Take the code from the first variant with Load Mode button and add it to the code of Sanity Posts ?

#1 - Load More

'use client'

import { useEffect, useState } from "react"


export default function Page() {
  const [items, setItems] = useState([]);
  const [visible, setVisible] = useState(24)

  const showMoreItems = () => {
    setVisible((prevValue) => prevValue + 24)
  }

  useEffect(() => {
    fetch("<https://jsonplaceholder.typicode.com/posts>")
    .then((res) => res.json())
    .then((data) => setItems(data))
  }, [])

  return (
    <div className='container py-4'>
      <div className="grid sm:grid-cols-2 gap-4">
      {items.slice(0, visible).map((item: any) =>(
        <div key={item.id} className="p-4 border rounded-lg space-y-2">
          <h2 className="tetx-xl font-bold">{item.title}</h2>
          <p>{item.body}</p>
        </div>
      ))}
      </div>

      <button className="py-3 w-full bg-blue-600 text-white mt-4 rounded-lg" onClick={showMoreItems}>Load More</button>
    </div>
  )
}
#2 - Sanity


import { getPostsApps } from "@/sanity-utils"
import Image from 'next/image'
import Link from 'next/link'

export default async function Apps() {
  const posts = await getPostsApps();

  return (
    <main className="container py-12">
      <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4 gap-12 2xl:gap-16'>
        {posts.map((post) => {
          return (
            <div key={post._id} className='space-y-4'>
              <Link
                href={post.url}
                target='_blank'
                title={post.name}
                rel="noopener noreferrer inline-block"
              >
                <div className="relative">
                  <Image
                    src={post.image}
                    alt={post.name}
                    width={460}
                    height={320}
                    className={post.border ? "object-cover rounded-lg w-full border border-slate-200" : "object-cover rounded-lg w-full"}
                  />
      
                  {post.new && <div className="absolute right-2 top-2 z-10 bg-blue-600 text-white text-xs py-0.5 px-1.5 rounded">New</div>}
                </div>
              </Link>

              <h2 className='text-2xl md:text-3xl font-bold leading-tight text-default'>{post.name}</h2>
              <p className='md:text-lg leading-relaxed font-light text-default'>{post.content}</p>
            </div>
          );
        })}
      </div>
    </main>
  )
}
[0...24] -
Sanity.io ’s documentation doesn’t tell you how to download the rest of the posts.
export async function getPosts(): Promise<Posts[]> {
  return client.fetch(
    groq`*[_type == "posts"] | order(_createdAt desc) [0...24] { //&& favorites
      _id,
      new,
      name,
      url,
      "image": image.asset->url,
      "alt": image.alt,
      border,
      content,
      category->,
      author->,
    }`
  )
}
AI Update

I can help you combine the "Load More" functionality with your Sanity posts! However, there's an important optimization detail to understand about pagination with GROQ.

According to Sanity's query optimization documentation, while it's tempting to fetch all posts and slice them client-side (like your first example), this approach is "surprisingly inefficient" for larger datasets. Instead, Sanity recommends filter-based pagination for "Load More" functionality.

Here's the recommended approach:

Updated Sanity Posts Component with Load More:

'use client'

import { useEffect, useState } from "react"
import Image from 'next/image'
import Link from 'next/link'
import type { Posts } from '@/types' // adjust import path as needed

export default function Apps() {
  const [posts, setPosts] = useState<Posts[]>([]);
  const [lastId, setLastId] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);

  const loadPosts = async (lastPostId: string | null = null) => {
    setLoading(true);
    const url = lastPostId 
      ? `/api/posts?lastId=${lastPostId}` 
      : '/api/posts';
    
    const res = await fetch(url);
    const data = await res.json();
    
    if (data.posts.length < 24) {
      setHasMore(false);
    }
    
    setPosts(prev => lastPostId ? [...prev, ...data.posts] : data.posts);
    if (data.posts.length > 0) {
      setLastId(data.posts[data.posts.length - 1]._id);
    }
    setLoading(false);
  };

  useEffect(() => {
    loadPosts();
  }, []);

  const showMoreItems = () => {
    loadPosts(lastId);
  };

  return (
    <main className="container py-12">
      <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4 gap-12 2xl:gap-16'>
        {posts.map((post) => {
          return (
            <div key={post._id} className='space-y-4'>
              <Link
                href={post.url}
                target='_blank'
                title={post.name}
                rel="noopener noreferrer inline-block"
              >
                <div className="relative">
                  <Image
                    src={post.image}
                    alt={post.name}
                    width={460}
                    height={320}
                    className={post.border ? "object-cover rounded-lg w-full border border-slate-200" : "object-cover rounded-lg w-full"}
                  />
      
                  {post.new && <div className="absolute right-2 top-2 z-10 bg-blue-600 text-white text-xs py-0.5 px-1.5 rounded">New</div>}
                </div>
              </Link>

              <h2 className='text-2xl md:text-3xl font-bold leading-tight text-default'>{post.name}</h2>
              <p className='md:text-lg leading-relaxed font-light text-default'>{post.content}</p>
            </div>
          );
        })}
      </div>

      {hasMore && (
        <button 
          className="py-3 w-full bg-blue-600 text-white mt-4 rounded-lg disabled:opacity-50" 
          onClick={showMoreItems}
          disabled={loading}
        >
          {loading ? 'Loading...' : 'Load More'}
        </button>
      )}
    </main>
  );
}

Create an API Route with Filter-Based Pagination (app/api/posts/route.ts):

import { client } from '@/sanity-utils' // adjust import path
import groq from 'groq'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const lastId = searchParams.get('lastId');
  
  // Filter-based pagination: fetch posts after the lastId
  const query = lastId
    ? groq`*[_type == "posts" && _id > $lastId] | order(_id) [0...24] {
        _id,
        new,
        name,
        url,
        "image": image.asset->url,
        "alt": image.alt,
        border,
        content,
        category->,
        author->,
      }`
    : groq`*[_type == "posts"] | order(_id) [0...24] {
        _id,
        new,
        name,
        url,
        "image": image.asset->url,
        "alt": image.alt,
        border,
        content,
        category->,
        author->,
      }`;
  
  const posts = await client.fetch(query, { lastId });
  
  return NextResponse.json({ posts });
}

Key changes and why they matter:

  1. Filter-based pagination with _id > $lastId - Instead of fetching all posts and slicing client-side, we use a GROQ filter to only fetch the next batch. This is much more efficient because the query engine can use indexes to find the starting point quickly.

  2. Keep [0...24] in the GROQ query - This limits each request to 24 posts. The [0...24] syntax uses GROQ's range notation where ... excludes the right-hand index, giving you exactly 24 items (indices 0-23).

  3. Track lastId instead of visible count - We track the _id of the last loaded post and use it to fetch the next batch from the server.

  4. Server-side pagination - Each "Load More" click fetches only the next 24 posts from Sanity, not all remaining posts.

  5. Append new posts - New posts are added to the existing array rather than replacing it.

Why this approach is better:

  • Performance at scale: Works efficiently even with thousands of posts
  • Reduced data transfer: Only fetches what you need, when you need it
  • Lower memory usage: Client doesn't hold all posts in memory
  • Faster initial load: First render only fetches 24 posts

Alternative: Use _createdAt for chronological ordering

If you want to maintain your order(_createdAt desc) sorting, adjust the filter:

*[_type == "posts" && _createdAt < $lastCreatedAt] | order(_createdAt desc) [0...24]

Then track lastCreatedAt instead of lastId in your component state. This is actually better for your use case since you're already ordering by _createdAt desc in your original query!

This filter-based approach is the recommended pattern for "Load More" functionality with Sanity and will keep your app performant as your content grows!

Show original thread
4 replies
If you search the docs , you’ll find a couple of examples for paginating your results.
Thank you. Unfortunately, that’s not it at all. I know how to make a limit on the number of posts in Sanity. In the documentation there is not a single example of how to make a button load more, not just theory, namely an example of code page posts.tsx. The video tutorials also show how to make a blog, but how to make dynamic pagination I have not found.
If possible, please make an example of how to connect my two options above. I think it will be very useful not only for me. Because all the examples or ready-made assemblies on the site are not full-fledged solutions for a blog.
Hi
user J
, under the guides section on the Sanity site is a example. Here the link:

https://hdoro.dev/minimal-sanity-io-pagination
Hi
user J
, Unfortunately, it didn’t help me. But I managed to find a solution. This is exactly the solution I was looking for. There are no concrete examples in the documentation. Mostly theory. I’m still new to Sanity and it’s hard to apply solutions from the documentation.

./app/page.tsx

import { getPostsApps } from "@/sanity-utils"
import BlogList from "@/components/BlogList";

export default async function Apps() {
  const posts = await getPostsApps();

  return (
    <main className="container py-12">

      <div className="text-center mb-12 md:mb-16">
        <h1 className="text-2xl xs:text-5xl font-black text-default">
          Category: APPS
        </h1>
      </div>

      <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4 gap-12 2xl:gap-16'>
        <BlogList posts={posts} />  
      </div>
    </main>
  )
}

./components/BlogList.tsx

"use client"

import { useState } from "react"

import Image from 'next/image'
import Link from 'next/link'

type Props = {
  posts: Posts[];
};

const BlogList = ({ posts }: Props) => {

  const articlesShown = 4;
  const [loadMore, setLoadMore] = useState(articlesShown);
  const showMoreArticles = () => {
    setLoadMore(loadMore + articlesShown);
  };

  return (
      <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4 gap-12 2xl:gap-16'>
        {posts.slice(0, loadMore).map((post) => {
          return (
            <div key={post._id} className='space-y-4'>
              <Link
                href={post.url}
                target='_blank'
                title={post.name}
                rel="noopener noreferrer inline-block"
              >
                <div className="relative">
                  <Image
                    src={post.image}
                    alt={post.name}
                    width={460}
                    height={320}
                    className={post.border ? "object-cover rounded-lg w-full border border-slate-200" : "object-cover rounded-lg w-full"}
                  />
      
                  {post.new && <div className="absolute right-2 top-2 z-10 bg-blue-600 text-white text-xs py-0.5 px-1.5 rounded">New</div>}
                </div>
              </Link>
              
              {/* <h3 className="text-xs font-bold uppercase text-default">
                {post.category.name}
               </h3> */}

              <h2 className='text-2xl md:text-3xl font-bold leading-tight text-default'>{post.name}</h2>
              <p className='md:text-lg leading-relaxed font-light text-default'>{post.content}</p>
            </div>
          );
        })}

        <div className="flex justify-center">
          {loadMore < posts?.length ? (
            <button
              type="button"
              className="group relative overflow-hidden bg-white px-2 py-3 text-sm md:text-base rounded-lg"
              onClick={showMoreArticles}
            >
              <div className="absolute inset-0 w-3  bg-[#8F00FF]  transition-all duration-[350ms] ease-out group-hover:w-full"></div>
              <span className="relative text-black group-hover:text-white ">
                Load More Articles
              </span>
            </button>
          ) : (
            <button
              type="button"
              className="bg-[#483248] text-[#FFF] px-2 py-3 text-sm md:text-base rounded-lg opacity-50 cursor-not-allowed"
              onClick={showMoreArticles}
              disabled
            >
              All articles loaded
            </button>
          )}
        </div>
        <div className="flex justify-center mt-5">
          Showing {loadMore} of {posts?.length} articles
        </div>
      </div>
      
  )
}
export default BlogList;

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?