How to add Load More button to Sanity posts with pagination?
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:
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.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).Track
lastIdinstead of visible count - We track the_idof the last loaded post and use it to fetch the next batch from the server.Server-side pagination - Each "Load More" click fetches only the next 24 posts from Sanity, not all remaining posts.
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 thread4 replies
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.