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

4 repliesLast updated: Dec 1, 2025

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) =&gt; res.json())
    .then((data) =&gt; setItems(data))
  }, [])

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

      &lt;button className="py-3 w-full bg-blue-600 text-white mt-4 rounded-lg" onClick={showMoreItems}&gt;Load More&lt;/button&gt;
    &lt;/div&gt;
  )
}
#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 (
    &lt;main className="container py-12"&gt;
      &lt;div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 3xl:grid-cols-4 gap-12 2xl:gap-16'&gt;
        {posts.map((post) =&gt; {
          return (
            &lt;div key={post._id} className='space-y-4'&gt;
              &lt;Link
                href={post.url}
                target='_blank'
                title={post.name}
                rel="noopener noreferrer inline-block"
              &gt;
                &lt;div className="relative"&gt;
                  &lt;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"}
                  /&gt;
      
                  {post.new &amp;&amp; &lt;div className="absolute right-2 top-2 z-10 bg-blue-600 text-white text-xs py-0.5 px-1.5 rounded"&gt;New&lt;/div&gt;}
                &lt;/div&gt;
              &lt;/Link&gt;

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

Why this approach is better:

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

Was this answer helpful?

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.

Related contributions