How to Build a Cursor Pagination Load More Feed in React and Flask
A "Load More" button that keeps showing the same first page almost always means the API ignores the cursor and returns from the top. The fix is cursor pagination: return the next N items whose id is GREATER than the cursor, and hand back a next_cursor so the client asks for "more after this". Here is how to wire the Flask endpoint and the React button together.
Why "Load More" keeps repeating the first page
The React button already sends the cursor it got back from the last response:
const r = await fetch(`/api/items?cursor=${cursor ?? 0}&limit=10`);
So the bug is on the server. The endpoint reads the cursor query param but never
uses it - it always slices from the top of the list:
@app.get("/api/items")
def items():
cursor = int(request.args.get("cursor", 0))
limit = int(request.args.get("limit", 10))
# BUG: ignores `cursor` - always returns the first page.
page = ITEMS[:limit]
next_cursor = page[-1]["id"] if page else None
return jsonify(items=page, next_cursor=next_cursor)
Every click re-fetches items 1..10, so the feed never advances.
Step 1 - page forward from the cursor on the server
Cursor pagination returns the next limit items whose id is strictly greater than
the cursor. next_cursor is the id of the last item on the page, or null once the
feed is exhausted so the client knows to stop:
@app.get("/api/items")
def items():
cursor = int(request.args.get("cursor", 0))
limit = int(request.args.get("limit", 10))
page = [it for it in ITEMS if it["id"] > cursor][:limit]
next_cursor = page[-1]["id"] if len(page) == limit else None
return jsonify(items=page, next_cursor=next_cursor)
The key detail is next_cursor = page[-1]["id"] if len(page) == limit else None.
A full page means there may be more, so return the last id. A short page means the
feed is exhausted, so return null. A raw list here stands in for a real query -
against a database the same logic becomes WHERE id > %s ORDER BY id LIMIT %s.
Restart Flask and page through it:
pkill -f "python3 app.py"; python3 app.py > /tmp/flask.log 2>&1 &
curl -s 'localhost:8000/api/items?limit=10'
curl -s 'localhost:8000/api/items?cursor=10&limit=10'
The first call returns ids 1..10 with next_cursor: 10; the second returns 11..20
with next_cursor: 20. No overlap, no gaps.
Step 2 - the React "Load More" button
The React side is already correct once the API pages forward. It appends each page
to the list and stores the returned cursor; when next_cursor comes back null the
button disables itself:
const [items, setItems] = useState<Item[]>([]);
const [cursor, setCursor] = useState<number | null>(0);
async function loadMore() {
const r = await fetch(`/api/items?cursor=${cursor ?? 0}&limit=10`);
const data = await r.json();
setItems((prev) => [...prev, ...data.items]);
setCursor(data.next_cursor);
}
<button onClick={loadMore} disabled={cursor === null}>
{cursor === null ? 'No more' : 'Load More'}
</button>
Storing next_cursor (not a page number) in React state is what makes this stable:
the client only ever asks for "the next items after id X".
Why cursors beat OFFSET for a feed
Offset pagination (OFFSET n) drifts when rows are inserted or deleted between
requests - users see duplicates or skip items, and deep offsets get slow because the
database still scans and discards the skipped rows. "Give me the next 10 after id X"
is always correct and, with an index on the sort column, is a fast index seek at any
depth. Real infinite-scroll feeds (X, Slack, the GitHub API) use an opaque encoded
token over a stable sort key like (created_at, id) rather than a raw id, so the sort
can be richer than one column - but the load-more contract is identical.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Reading the cursor query param and returning items with id greater than it
- Computing next_cursor as the last page id, or null when a short page ends the feed
- Wiring a React button that appends pages and disables on a null cursor
FAQ
How do I build a "Load More" button that does not repeat items?
Have the API return only items whose id is greater than a cursor the client sends, plus a next_cursor equal to the last id on the page. The React button stores next_cursor and passes it on the next request, so each click fetches the next page instead of the first.
Why does my Load More feed keep showing the same page?
The API is ignoring the cursor query parameter and slicing from the top of the list every time. Fix it by filtering to items with id greater than the cursor before applying the limit, so each page starts where the last one ended.
What should next_cursor return when the feed is exhausted?
Return null. Set next_cursor to the last item's id only when the page is full (its length equals the limit); a short page means there are no more rows, so returning null lets the client disable the button.
Is cursor pagination better than offset pagination for infinite scroll?
Yes for feeds. Offset pagination drifts when rows are inserted or deleted mid-scroll, causing duplicates or skips, and deep offsets get slow. Cursor pagination pages forward from the last-seen id, which stays correct and can use an index seek at any depth.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →