Skip to main content

October 19, 2026

An empty page is not the end of the list

Logged-in shoppers saw an empty My orders screen while older rows still existed. hasMore followed filtered rows, not the raw API page.

Sander Korf4 min read
paginationtanstack-querynextjsreactapi

On a fashion webshop, returning customers open My orders to track deliveries and re-download invoices. The Next.js App Router page asks a commerce backend order API for five rows at a time, newest first. Some rows are soft-deleted: they still ride along in the JSON payload, but the UI is supposed to hide them after fetch. A logged-in customer whose five newest orders were all soft-deleted saw You have no orders yet and never got a Load more button, even though older orders still lived on later API pages.

That customer is not edge-case noise. Filtering happens after each page fetch. Pagination metadata must describe the raw page the server returned, not the shorter list you paint once deleted rows are stripped out.

Names worth defining once

An infinite query (TanStack Query) keeps every fetched page in memory and appends the next slice when the reader clicks Load more. The page param is the page number you pass into the fetcher (here, 1-based). Hydration is when the server-rendered first page is copied into the client cache so the island does not refetch on mount. A soft-deleted row is still present in the API response with a flag; the client drops it instead of the backend omitting it from the page.

What the reader actually saw

Flow: server component loads page 1, filters deleted rows, dehydrates the infinite query, client hydrates, Load more asks for page 2 using the last page param.

Symptom: when every row on page 1 was soft-deleted, the filtered slice was empty, Load more vanished, and the empty-state copy fired as if the customer had never bought anything.

Trap in one line: hasMore was computed from the filtered page, so a page that filtered to zero rows ended pagination and triggered the empty state.

Where the wiring went wrong

The mapper looked like this after filtering:

const visible = raw.orders.filter(notDeleted)
return { orders: visible, page, hasMore: visible.length > 0 && page * limit < raw.total }

Two separate bugs stacked. Empty state used orders.length === 0 without checking whether pagination could still advance. Server hydration always seeded pageParams: [1] even when the resolver had already walked forward to page 3 to find visible rows.

Fix in one line: derive hasMore from the raw page, skip forward over empty pages with a cap, render empty only when orders.length === 0 && !hasMore, and hydrate the page number you actually returned.

const visible = raw.orders.filter(notDeleted)
return { orders: visible, page, hasMore: raw.orders.length > 0 && page * limit < raw.total }
 
async function resolvePage(fetchPage, page, limit, last = page + 5) {
  const mapped = toPage(await fetchPage(page), page, limit)
  if (mapped.orders.length || !mapped.hasMore || page >= last) return mapped
  return resolvePage(fetchPage, page + 1, limit, last)
}
// hydrate: setQueryData(['orders', locale], { pageParams: [first.page], pages: [first] })

The cap matters because a long streak of empty pages should fall back to Load more (or an error), not spin in a tight loop. Hydrating page 3 as page 1 is worse than it sounds: the next Load more can refetch pages the customer already saw, because the client thinks it is still on page 1.

I also put locale in the query key. Cached totals and dates were already formatted for one locale; reusing that cache after a language switch showed the right rows with the wrong labels.

When this pattern does not apply

Skip the forward skip when the backend filters before paging, when the API exposes a cursor with an honest hasNext, or when deleted rows never appear in the payload.

Interactive

My orders (fake fashion webshop)

Fifteen orders, five per page. The server returns soft-deleted rows; the client filters them out after fetch. Toggle Naive vs Fixed hasMore wiring.

  • Order #1visible
  • Order #2visible
  • Order #3visible
  • Order #4visible
  • Order #5visible

Fetched pages: 1

hasMore from raw page

First page has five visible orders. hasMore follows whether the API returned any rows for that page number.

Pagination wiring
Server rows

Flip the switch to Naive and delete the first five orders. The card shows the empty state and Load more stays disabled. Switch to Fixed with the same deletions: the demo requests pages 1 and 2, lists orders 6-10, and the fetched-pages trail reads 1, 2. That is the same class of bug without needing a real account.

Pagination is a contract with the upstream page boundary. Client-side cleanup is fine; just do not let it rewrite whether another page exists.


Happy coding! Sander