Mastering Data Retrieval in Sanity.io: GROQ, Real‑Time, and Pagination
Learn how to write efficient GROQ queries, hook them into real‑time listeners, and paginate data in Sanity.io. A concrete example shows live updates and pagination with practical checks and trade‑offs.
09 Sept 2025, 20:47 UTC

The Problem: Fetching the Right Data
When building a website or app that consumes a headless CMS, developers often struggle with two core pain points: getting exactly the data they need without over‑fetching, and keeping the UI fresh when editors push changes. Sanity.io addresses both with its native query language, GROQ, and a real‑time subscription model. This post walks through how to write efficient GROQ queries, hook them into live listeners, and paginate results, ending with a concrete, verifiable example.
GROQ: A JSON‑Like Query Language
GROQ (Graph‑Relational Object Queries) is Sanity’s query language. Its syntax is intentionally close to JSON, which makes it feel natural for JavaScript developers. A basic query looks like:
client.fetch('*[_type == "post"]')
Key features:
- Filtering – use boolean expressions, e.g.,
*[_type == "post" && title match /blog/]. - Projection – select fields:
*[_type == "post"][0...5]{title, author->, _createdAt}. - Sorting –
*[_type == "post"] | order(_createdAt desc). - Operators –
in,match, regex, and array operators.
Because GROQ returns plain JSON, you can use the result directly in React state, server‑side rendering, or any other JavaScript context.
Real‑Time Updates with GROQ Listeners
Sanity’s JavaScript client exposes a listen method that turns a GROQ query into a WebSocket subscription:
const subscription = client.listen('*[_type == "post"]', {params: {}})
.subscribe(update => {
console.log('New data:', update.result)
})
When any document matching the query changes, the callback receives an update object containing the new data. This eliminates the need for polling and keeps the UI in sync with editor edits. Remember to unsubscribe when the component unmounts to free the socket:
return () => subscription.unsubscribe()
Limitations: each listener consumes a WebSocket connection; for large sites, throttle the number of active listeners or batch queries.
Pagination Strategies and Trade‑offs
Fetching all posts at once is rarely desirable. Sanity supports offset‑based pagination natively:
const pageSize = 10
const page = 2
const query = '*[_type == "post"][${page * pageSize}...${(page + 1) * pageSize}]'
client.fetch(query)
Offset pagination is simple but becomes inefficient for deep pages because the server must skip page * pageSize documents. For large collections, consider cursor‑based pagination using _id or _createdAt as a cursor:
const lastId = 'abcd1234'
const query = '*[_type == "post" && _id > $lastId][0...10]'
client.fetch(query, {lastId})
Cursor pagination avoids the cost of skipping rows but requires the client to track the last seen value.
Putting It All Together: A Worked Example
Assume a dataset named production containing blog posts. You want a component that shows the latest five posts, updates live when an editor edits a post, and allows the user to load more posts.
- Setup the Sanity client (run in Node or browser):
import {createClient} from "@sanity/client"
const client = createClient({
projectId: "YOUR_PROJECT_ID",
dataset: "production",
useCdn: false, // set true for static builds
apiVersion: "2024-09-22"
})
- Define the query and listener:
const pageSize = 5
let page = 0
let cursor = null
function fetchPage() {
const query = cursor
? '*[_type == "post" && _id > $cursor][0...$pageSize]'
: '*[_type == "post"][0...$pageSize]'
return client.fetch(query, {cursor, pageSize})
}
const subscription = client.listen('*[_type == "post"]', {params: {}})
.subscribe(update => {
// Update the UI with new data
console.log('Live update:', update.result)
})
- Render the data and load more button (pseudo‑React):
function BlogList() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetchPage().then(setPosts)
return () => subscription.unsubscribe()
}, [])
const loadMore = () => {
const lastId = posts[posts.length - 1]?._id
cursor = lastId
page++
fetchPage().then(newPosts => setPosts([...posts, ...newPosts]))
}
return (
<div>
{posts.map(p => <PostCard key={p._id} post={p} />)}
<button onClick={loadMore}>Load More</button>
</div>
)
}
To verify the query works, run:
sanity dataset query production '*[_type == "post"][0...5]'
Check the returned JSON for the expected title and _id fields. In a browser console, observe the live update console.log when you edit a post in Studio.
Limitations and Practical Checks
- GROQ only works in Studio v3 and newer. Using it in v2 will throw syntax errors.
- Large unindexed queries can slow down. Use Sanity’s query logs to spot high‑latency queries and add indexes via
sanity schema createIndex. - Offset pagination is simple but not scalable for deep pages. Switch to cursor pagination for >1,000 items.
- Real‑time listeners consume WebSocket bandwidth. If your app has many simultaneous listeners, consider debouncing or batching updates.
- Some GROQ functions are experimental; review Sanity’s release notes before using them in production.
Practical check: enable client.fetch(...) in a small Node script and log the time. Compare offset vs cursor queries on a dataset with 10,000 posts to see the performance difference.
Actionable Takeaways
- Use GROQ for type‑safe, expressive queries that return plain JSON.
- Turn a GROQ query into a live listener to keep UI in sync without polling.
- Prefer cursor‑based pagination for large collections; reserve offset for small or shallow lists.
- Always test queries with realistic data, monitor execution times, and add indexes where needed.
- Unsubscribe listeners on component unmount to avoid memory leaks and excess bandwidth.
With these patterns, you can build responsive, efficient interfaces that stay in lockstep with your Sanity Studio without sacrificing performance.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.