product-api: How to Make Product Browsing Scale Without Offset Pagination

A reference implementation for stable cursor pagination, filterable catalog queries, and infinite-scroll UX that still behaves when the dataset gets big.

8 min read • View on GitHub • More from artiverma-00

A vast warehouse of product cards stretches into the distance while a narrow cursor line threads through them. In the foreground, a stamped token locks between two nearly identical items, showing how order survives scale.
The repo’s core idea is not just paging data. It is preserving a stable place in a large catalog, even when nearby rows look the same.
Key Takeaways

The hidden cost of offset pagination

Offset pagination looks simple because it is simple. It is also fragile. As a catalog grows, deep pages get slower, inserts can shift records underneath you, and the same item can appear twice or vanish between requests.

That is the failure mode this repo is built to avoid. It treats browsing as an ordering problem first and a UI problem second.

ProblemOffset paginationStable cursor pagination
Deep pagesGets slower as OFFSET climbsKeeps a fixed access pattern
New insertsCan shift results mid-browsePreserves relative position
Duplicate or skipped rowsCommon under concurrent writesAvoided with deterministic ordering
Infinite scroll fitWorks, but feels brittleFits naturally
Filter changesOften awkward to re-pageCan be merged into the same query shape
ImplementationEasy to start, hard to trustSlightly more logic, much more stable

The stable cursor trick that fixes it

The smartest move in the repository is also the smallest: build the cursor from createdAt and id. That pair gives the backend a deterministic sort order even when two products share the same timestamp.

The cursor is not just a pointer. It is a rule for deciding which item comes next when timestamps collide.

where.OR = [
  { createdAt: { lt: cursor.createdAt } },
  { createdAt: cursor.createdAt, id: { lt: cursor.id } },
];

That second clause is the whole point. Without it, two products that land on the same millisecond can create ambiguity. With it, the repository can walk the dataset in a stable order instead of guessing.

The cursor itself is encoded, which keeps the API payload clean and lets the frontend treat pagination as a simple token exchange. Under the hood, though, this is really a sorting contract.

How the backend builds filterable pages

The backend does not bolt filtering onto pagination as an afterthought. It constructs the query dynamically, so category filters, price ranges, and cursor constraints all live in the same shape.

function buildWhereClause({ categories, minPrice, maxPrice, cursor }) {
  const where = {};

  if (categories?.length) {
    where.category = { in: categories };
  }

  if (minPrice != null || maxPrice != null) {
    where.price = {};
    if (minPrice != null) where.price.gte = minPrice;
    if (maxPrice != null) where.price.lte = maxPrice;
  }

  if (cursor) {
    where.OR = [
      { createdAt: { lt: cursor.createdAt } },
      { createdAt: cursor.createdAt, id: { lt: cursor.id } },
    ];
  }

  return where;
}

That matters because catalog browsing rarely happens without filters. A product browser has to answer a specific question, not just dump records in order. The repo’s architecture keeps that question and the paging model aligned.

Why the seed script matters more than it looks

A close-up of a stamping press producing identical product tags in batches while a counter climbs toward a large catalog. The machine suggests that scale is not cosmetic, but built into the demo from the start.
The seed script is not filler. It is what makes the pagination strategy worth trusting.

A lot of demos cheat with tiny datasets. This one does not. The seed script batches inserts, uses deterministic ObjectIds, and targets a large enough volume to make the browsing pattern feel real.

That is more than convenience. It is a claim about the product shape. If the architecture only works on 100 rows, it is not an architecture for browsing. It is a tutorial with a UI.

The front end keeps the experience frictionless

On the frontend, the repo uses IntersectionObserver to fetch the next page as the user approaches the end of the list. There is no ceremonial button clicking. The list simply extends.

useEffect(() => {
  const observer = new IntersectionObserver(entries => {
    if (entries[0].isIntersecting) {
      loadNextPage();
    }
  });

  if (sentinelRef.current) observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, [sentinelRef, loadNextPage]);

The more interesting UX detail is the split between draft filters and active filters. Users can change settings without triggering a network request on every keystroke. That makes the interface feel deliberate rather than twitchy.

What this repo is really teaching

The repo is a blueprint for a durable product browser. It uses native browser APIs, a compact Express and Prisma backend, and a cursor model that keeps its promises when rows collide.

PatternWhat it optimizesWhat it costs
Naive cursor paginationSimplicityRisk of duplicate or missing rows when sort keys tie
Offset paginationEasy mental modelPoor deep-page behavior and unstable browsing
Stable keyset paginationPredictable navigation under loadA little more query logic
This repo’s full stack patternA coherent browsing systemMore thought up front, less friction later

That is why the project is useful even if you never copy it line for line. It shows how to design around the real problem, which is not just fetching products. It is preserving continuity while the catalog and the user both keep moving.