Infinite scroll has a reputation for being user-hostile, and mostly it deserves it. But the complaints are rarely about the scrolling itself — they're about three specific failures that are all avoidable.
The three failures
The first is the back button. You scroll through sixty items, click the fifty-eighth, read it, hit back, and land at the top of a list of ten. Every bit of work you did is gone. This single bug is responsible for most of the hatred aimed at the pattern.
The second is offset pagination. If your query is [20...30] and something new is published while the reader is mid-scroll, every subsequent page shifts by one. They'll see one item twice and never see another at all. Nobody notices this in testing because test data doesn't change under you.
The third is the keyboard and screen reader experience. A scroll-triggered fetch is invisible if you're not scrolling with a mouse, and a footer that retreats forever is genuinely hostile.
Cursors, not offsets
The fix for duplicate and skipped items is keyset pagination. Instead of "skip 20", you say "everything after this exact item". Sort by a pair that's guaranteed unique — a timestamp plus an id — and ask for rows strictly after the last one you rendered.
*[_type == "post" && (
publishedAt < $cursorDate ||
(publishedAt == $cursorDate && _id < $cursorId)
)] | order(publishedAt desc, _id desc) [0...$limit]The tie-break on _id matters more than it looks. Two posts published in the same minute will otherwise order non-deterministically between requests, and you're back to duplicates.
Encode the pair as an opaque string. If you later change the sort order, old cursors fail to decode and you fall back to page one — which is far better than confidently serving wrong results.
Restoring position
The back button fix is less elegant but not hard: cache what you loaded, keyed by the current filter state, and restore it before the browser tries to restore scroll.
- Write the accumulated items and the scroll offset to
sessionStorageas the reader goes. - On mount, read the cache synchronously during the first render, not in an effect. If you populate in an effect, the browser has already tried and failed to restore scroll against a short page.
- Key the cache by the serialised filters so that changing a tag correctly starts fresh.
- Use
sessionStorage, notlocalStorage. Nobody wants yesterday's scroll position.
Make it work without scrolling
Render the first page on the server. That gives you a page that works with JavaScript disabled, and it means search engines index something real.
Then keep an actual Load more button in the DOM permanently, and treat the intersection observer as an enhancement that clicks it for you. One code path, keyboard accessible by default, and it degrades to a perfectly ordinary paginated list.
Finally, announce new items in a live region — "showing 15 of 42" — so the fetch isn't silent for anyone using a screen reader.
If you can't articulate what happens on back-navigation, you haven't finished building infinite scroll.