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.

Fullstack Engineerfullstackpaginationcursor

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

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

Implement Cursor-Based PaginationFullstack projectRepair Broken API PaginationFullstack projectFilter a List with useEffect in ReactFullstack projectFullstack roadmapStep by step to hiredFullstack interview questionsSTAR answersAll Fullstack projectsProjects hub

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 →