Shopify Hydrogen Pagination: Cursors in the URL

Hydrogen pagination is two calls: getPaginationVariables(request, {pageBy}) in the loader turns the cursor and direction search params into first/last/startCursor/endCursor for your Storefront API query, and <Pagination connection={...}> renders the result and builds the next and previous links. The cursor lives in the URL, so a shopper can open a product, press back and land on the same page of the list.

This is the React Router based Hydrogen, package line 2026.4.x, written against Storefront API 2026-04. If you installed @shopify/hydrogen today without a version, you did not get it. More on that at the end.

What does getPaginationVariables read from the URL?

Two search params, both optional. cursor is the opaque cursor from the previous response, and direction is either next or previous; anything other than the string previous is treated as next. From those, the utility returns one of two shapes:

// direction=next (or absent)
{first: pageBy, endCursor: cursor ?? null}

// direction=previous
{last: pageBy, startCursor: cursor ?? null}

The Storefront API’s own pagination rules say forward pagination uses first with after and backward uses last with before, and you cannot mix them in one request. Because the utility only ever populates one pair, the invalid combination cannot be expressed. The default pageBy is 20; the ceiling for either first or last is 250 per request, and a bigger page is not a cheaper one, because the query cost scales with the nodes you ask for.

The function throws if you hand it anything without a url property, with the message getPaginationVariables must be called with the Request object passed to your loader function. Pass the loader’s request, not a URL string.

What does the query have to return?

Four pageInfo fields, and the component checks for each one before it renders. The check is in usePagination, and a missing field throws with the text The Pagination component requires \pageInfo.startCursor` to be a part of your query` (or whichever field you dropped). The query also has to accept all four variables even though only two are ever set, because the same document serves both directions:

const COLLECTION_QUERY = `#graphql
  query Collection(
    $handle: String!
    $first: Int
    $last: Int
    $startCursor: String
    $endCursor: String
  ) {
    collection(handle: $handle) {
      id
      title
      products(
        first: $first
        last: $last
        before: $startCursor
        after: $endCursor
      ) {
        nodes {
          id
          handle
          title
          featuredImage { url altText width height }
          priceRange { minVariantPrice { amount currencyCode } }
        }
        pageInfo {
          hasPreviousPage
          hasNextPage
          startCursor
          endCursor
        }
      }
    }
  }
` as const;

Declare every variable as nullable, as above. The utility only ever sets one pair, and a non-null Int! for the other would fail validation on every other request.

How do the loader and the component fit together?

The loader calls the utility, runs the query, and returns the collection. The route component hands the products connection to <Pagination> and uses the render prop for everything else:

import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
import {useLoaderData, Link} from 'react-router';
import type {Route} from './+types/collections.$handle';

export async function loader({request, params, context}: Route.LoaderArgs) {
  const paginationVariables = getPaginationVariables(request, {pageBy: 24});
  const {collection} = await context.storefront.query(COLLECTION_QUERY, {
    variables: {handle: params.handle, ...paginationVariables},
  });
  if (!collection) {
    throw new Response('Not found', {status: 404});
  }
  return {collection};
}

export default function Collection() {
  const {collection} = useLoaderData<typeof loader>();
  return (
    <Pagination connection={collection.products}>
      {({nodes, isLoading, PreviousLink, NextLink}) => (
        <>
          <PreviousLink>{isLoading ? 'Loading...' : 'Previous'}</PreviousLink>
          <ul>
            {nodes.map((product) => (
              <li key={product.id}>
                <Link to={`/products/${product.handle}`}>{product.title}</Link>
              </li>
            ))}
          </ul>
          <NextLink>{isLoading ? 'Loading...' : 'Next'}</NextLink>
        </>
      )}
    </Pagination>
  );
}

PreviousLink and NextLink render a React Router Link with preventScrollReset: true already set, and they render nothing at all when hasPreviousPage or hasNextPage is false. NextLink points at ?direction=next&cursor=<endCursor> with the rest of the current search string preserved, so a sort or filter param you added survives the page change. The render prop also exposes hasNextPage, hasPreviousPage, nextPageUrl, previousPageUrl and state for the cases where a link is not what you want. The component reference lists all of them.

Why does the list grow instead of replacing itself?

Click Next in that example and you get 48 products, not 24. The link carries a state object with the nodes and pageInfo seen so far, keyed by namespace. After hydration (window.__hydrogenHydrated is set in an effect), usePagination prepends or appends the new page’s nodes to state.pagination[namespace].nodes depending on direction, and reads hasNextPage from that accumulated state rather than from the fresh connection.

Reload the page, or arrive from a shared link, and there is no history state, so you get exactly one page: the one the cursor in the URL points at. The pagination guide gives that as the reason the state belongs in the URL: a shopper can leave for a product page and come back to the same scrolled position. The browser’s back button restores the history state, so the accumulated list and the scroll position come back together.

The hook also handles the reset. If the pathname or any non-pagination search param changes, say the shopper picks a different sort, it issues a replace navigation that strips cursor and direction and clears the accumulated state. Changing the filter restarts from page one without you writing that logic.

How do you turn it into infinite scroll?

Keep the links. Add an observer that follows the next one when it comes into view. Shopify’s infinite scroll recipe adds react-intersection-observer and does this inside the render prop:

import {useEffect} from 'react';
import {useNavigate} from 'react-router';
import {useInView} from 'react-intersection-observer';

function ProductsGrid({nodes, hasNextPage, nextPageUrl, state, NextLink}) {
  const {ref, inView} = useInView();
  const navigate = useNavigate();

  useEffect(() => {
    if (inView && hasNextPage) {
      void navigate(nextPageUrl, {
        replace: true,
        preventScrollReset: true,
        state,
      });
    }
  }, [inView, navigate, state, nextPageUrl, hasNextPage]);

  return (
    <>
      {nodes.map((product, i) => (
        <ProductCard key={product.id} product={product} loading={i < 8 ? 'eager' : 'lazy'} />
      ))}
      <NextLink ref={ref}>Load more</NextLink>
    </>
  );
}

Without replace: true, every scrolled page becomes a history entry and the back button walks the shopper through each page in reverse. Passing state through is what keeps the accumulated nodes; navigate to the URL without it and the list snaps back to a single page. The NextLink stays in the markup, so a crawler or a shopper with JavaScript off still has a real link to follow.

Can two lists share one page?

Yes, with namespace. Pass the same string to both halves: getPaginationVariables(request, {pageBy: 12, namespace: 'related'}) and <Pagination connection={related} namespace="related">. The params become related_cursor and related_direction, and the accumulated state is kept under state.pagination.related, so a search results list and a recommendations rail can page independently in the same URL. Leave the namespace off and both lists read the same cursor param, a bug that only shows up when someone clicks Next on the second list.

Does this survive the Hydrogen rewrite?

Not as written. As of October 2026, npm view @shopify/hydrogen dist-tags reports latest as 2026.10.0, and that package is the framework-agnostic SDK Shopify announced as a developer preview on 17 June 2026. Its README describes it as a “framework agnostic Shopify storefront SDK and packaged agent skills”. Unpack it and there is no Pagination export and no getPaginationVariables. Collection browsing is a createCollectionStore core plus a CollectionProvider / useCollection binding under @shopify/hydrogen/react, and the bundled hydrogen-collection-browser skill tells the coding agent to render load-more as a GET link carrying ?after=<endCursor>, clearing before and after whenever a filter or sort changes. Same idea, different param names, and the history-state accumulation is yours to build.

The 2026.4.x line is where Pagination lives, with 2026.4.7 the newest at the time of writing and the shopify.dev reference still published under 2026-04. Pin to it explicitly:

npm install @shopify/[email protected]

The changelog entry says nothing about how long that line is maintained, so treat the upgrade as a replatform of your routes rather than a version bump. The same rewrite touches the Hydrogen caching and cart handler surfaces, so budget for those at the same time.

What I would do

For a storefront on the React Router Hydrogen, use getPaginationVariables and <Pagination> exactly as above, keep the links visible, and only add the observer once the plain version works with JavaScript disabled. Pin the package to 2026.4. Do not install 2026.10.0 on an existing storefront to get pagination; it is a preview of a different toolkit, and the route above will not compile against it.

For a storefront starting this quarter, it comes down to appetite for a preview. If the store has a launch date, start on 2026.4 with this pattern. If it is an experiment, the new SDK’s after cursor in the URL is the shape to learn, and nothing in the old state accumulation carries over.

Whoooop builds and maintains Hydrogen storefronts, including the collection, search and filter routes where pagination lives, and we can scope what the move to the new SDK means for a storefront you already run. Our Shopify development page covers the work.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch