Skip to main content
ASoc
Tutorial

Next.js Infinite Scroll: We Measured 111 Cards and Shipped One Grid

Our whole catalogue page is 485 KB of HTML that ships as 34.7 KB gzipped, with all 111 links crawlable. The measurement first, then the implementation.

The ASoc Team10 min read

Infinite scroll in Next.js is a fetch-scheduling decision, not a UX default. Before adding one, weigh what the whole list costs prerendered: our 111-card catalogue page compiles to 485 KB of HTML that leaves the server as 34.7 KB gzipped, with every card's link in the markup. An infinite scroller would have saved bytes we were never sending.

This post is the measurement first, then the implementation for the case where the measurement says yes.

The page we did not paginate

/templates renders every product in the catalogue in one grid — no pages, no "load more", no observer. src/components/organisms/TemplatesExplorer.tsx is 141 lines, and the list part of it is four:

{results.map((p, i) => (
  <TemplateCard
    key={p.slug}
    product={p}
    priority={i === 0}
    frameworksLabel={p.editions.map((e) => FRAMEWORK_LABELS[e.framework]).join(" · ")}
  />
))}

Here is what that produces, read off .next/server/app/templates.html after npm run build:

MeasurementValue
Cards rendered111
Prerendered HTML485,440 bytes
Same file, gzipped34,658 bytes
Average markup per card~3.5 KB raw
<img> elements113
…of which loading="lazy"110
Internal links in the document256 (134 unique)
RSC payload (templates.rsc)58,942 bytes

The raw number is the one people quote when they argue for infinite scroll, and it is the wrong one: nothing serves uncompressed HTML. 34.7 KB is roughly one medium photograph, and it is the entire catalogue, delivered from the CDN with no round trip, no client component, no observer, no loading state.

Reproduce it on your own list before you decide:

npm run build
wc -c < .next/server/app/<route>.html
gzip -c .next/server/app/<route>.html | wc -c

Why the markup is not the weight

Because the images are, and they are already deferred. 110 of the 113 <img> elements on that page carry loading="lazy", so the browser fetches the covers for the cards near the viewport and ignores the rest — the exact saving an infinite scroller advertises, minus the JavaScript.

The one card that must not be lazy is the first, and getting that wrong is a defect this codebase actually shipped. The opening card's cover is the page's Largest Contentful Paint element; leaving it in the lazy set cost about 1.7 seconds of load delay, because the browser refused to start the fetch until layout had proven the element was visible. Hence priority={i === 0} — one eager image, everything after it lazy. If you take one thing from this post and you are not adding infinite scroll, take that line.

What infinite scroll would have cost

Three things, in ascending order of how much they matter.

A client boundary. The grid is currently rendered by a Client Component only because of the filter chips; the cards themselves are server-rendered. An observer-driven list moves list state to the client — the accumulated pages, the cursor, the pending flag — and the cards get re-rendered from client state instead of arriving as HTML.

The back button. The classic infinite-scroll failure: a visitor scrolls to item 80, opens a product, presses Back, and lands at item 12 with everything after it gone. Fixing it properly means writing the cursor into the URL and restoring scroll position on mount — which is most of a pagination implementation, plus an observer.

Links a crawler can see. This is the decisive one for a storefront. Googlebot renders JavaScript but does not scroll; whatever is in the initial paint is what gets discovered. All 111 product URLs are in our HTML today. Behind an infinite scroller with a page size of 12, 99 of them would depend on some other page linking to them.

We have already paid for that lesson in a different form. When product pages were reachable mostly from a hand-maintained list, 38 of them ended up in Search Console's Discovered — currently not indexed. The fix was structural: every product page now closes with a rail of six same-category siblings, chosen as a wrapping sliding window over catalogue order so every product receives exactly six inbound links — not "newest", not "random", both of which re-orphan the tail. A grid that hides 90% of its links behind a scroll listener recreates precisely the problem that rail was built to solve.

And there is a smaller one nobody notices until launch: the footer. Our home page's footer holds 20 links. Under an infinite scroller, the footer is only reachable when the list runs out — which, on an unbounded list, is never.

When infinite scroll is the right answer

It earns its place when at least one of these is true:

  • The list is unbounded or effectively so. An activity feed, a log stream, a notifications panel. There is no "all of it" to prerender.
  • Rows are expensive per item. Rendering 10,000 rows costs main-thread time no compression trick fixes. That is a virtualization problem, not a pagination problem, and the trade-offs are worked through in data table virtualization.
  • The page is behind auth and not indexed. Crawl discovery stops being an argument the moment nothing is crawling it — which is why dashboards are where infinite scroll genuinely belongs.
  • The list is personalised per user. It cannot be prerendered anyway, so a first page plus fetch-on-demand beats a big dynamic render.

Rough numbers to reason with, from the two lists this site actually ships: at 111 cards, the whole list costs 34.7 KB gzipped; at 94 blog cards with longer copy, /blog costs 81.0 KB gzipped. That trajectory is where the decision lives. Somewhere past a few hundred rich items the all-at-once page stops being free, and the honest trigger is a measurement on your own markup, not a rule of thumb.

An implementation that keeps what it should

This repository does not ship an infinite scroller, so — unlike everything above — the code in this section was written for the article rather than copied out of src/. It is the shape we would use, built from pieces this codebase does run: a Server Action for the data, a Client Component for the observer, and the URL as the source of truth for how much has been loaded.

The Server Action, which owns the cursor:

"use server";
import { catalog } from "@/data/catalog";

const PAGE = 12;

export async function loadProducts(cursor: number) {
  const slice = catalog.slice(cursor, cursor + PAGE);
  return {
    items: slice.map((p) => ({ slug: p.slug, name: p.name, seoLabel: p.seoLabel })),
    nextCursor: cursor + PAGE < catalog.length ? cursor + PAGE : null,
  };
}

The client half, with the three things most tutorials skip — a real button, a live region, and a guard against firing twice:

"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { loadProducts } from "./actions";

export function InfiniteList({ initial, initialCursor }) {
  const [items, setItems] = useState(initial);
  const [cursor, setCursor] = useState<number | null>(initialCursor);
  const [pending, startTransition] = useTransition();
  const sentinel = useRef<HTMLDivElement>(null);

  function loadMore() {
    if (cursor === null || pending) return;
    startTransition(async () => {
      const next = await loadProducts(cursor);
      setItems((prev) => [...prev, ...next.items]);
      setCursor(next.nextCursor);
      history.replaceState(null, "", `?loaded=${next.nextCursor ?? items.length}`);
    });
  }

  useEffect(() => {
    const el = sentinel.current;
    if (!el || cursor === null) return;
    const io = new IntersectionObserver(
      ([entry]) => entry.isIntersecting && loadMore(),
      { rootMargin: "600px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [cursor, pending]);

  return (
    <>
      <ul>{items.map((p) => <li key={p.slug}>{p.name}</li>)}</ul>
      <p aria-live="polite" className="sr-only">
        {pending ? "Loading more templates" : `${items.length} templates loaded`}
      </p>
      <div ref={sentinel} />
      {cursor !== null && (
        <button type="button" onClick={loadMore} disabled={pending}>
          {pending ? "Loading…" : "Load more"}
        </button>
      )}
    </>
  );
}

Four decisions in there are worth naming:

  1. The button is not a fallback, it is the control. The observer is a convenience layered on top. A keyboard or screen-reader user must have something focusable that advances the list; a bare sentinel gives them nothing.
  2. rootMargin: "600px" starts the fetch before the sentinel is visible, which is the difference between "seamless" and "spinner at the bottom of every screen".
  3. useTransition plus the pending guard stops the double-fire that happens when the observer re-triggers while a request is in flight.
  4. history.replaceState keeps the loaded count in the URL without pushing a history entry, so Back leaves the page instead of unwinding scroll positions. Restoring the deeper list on return then costs one server render of ?loaded=N — which is also, not coincidentally, a URL a crawler can follow.

If you would rather not own any of that, the same job in plain pagination is ?page=2 links, server-rendered, indexable, and back-button-correct for free. The searchParams pattern for it is in product filtering with searchParams.

Mistakes and how they show up

MistakeHow it shows upFix
Adding infinite scroll to a bounded, indexable listFewer crawlable links; pages drift into Discovered — currently not indexedPrerender the whole list; measure the gzipped page first
Quoting the uncompressed HTML sizeA "474 KB page" that is 34.7 KB on the wireCompare gzipped bytes against the JS the scroller adds
No cursor in the URLBack returns to the top with 80 items losthistory.replaceState on each load; restore on mount
Sentinel with no buttonUnreachable by keyboard, invisible to screen readersA real <button>, with the observer as an enhancement
No in-flight guardTwo or three pages load per intersection; duplicate keyspending check plus a stable key per item
Footer below an unbounded listContact, licence and legal links become unreachableKeep the footer out of the scroll container, or paginate
Leaving the first card lazyLCP element starts loading late — ~1.7 s in our casepriority on the first item only

Frequently asked questions

Does Google index content loaded by infinite scroll? Only what it can reach. Googlebot renders JavaScript but does not scroll or click "load more", so treat the initial HTML as the crawlable surface. If the items matter for search, give each one a link that exists in that HTML — from the list, from a paginated variant, or from sibling pages.

Is infinite scroll bad for Core Web Vitals? It does not have to be, but it adds two risks: main-thread work from a growing client-side list, and layout shift when appended items resize the container. Reserve space for incoming rows and keep the page size small enough that appending is cheap.

Can I use Server Actions instead of a route handler? Yes — a Server Action is a POST endpoint with the arguments typed for you, which is exactly this use case. Choose a route handler when you need the raw request, a status code, or a real URL. That distinction is worked through in Server Actions vs route handlers.

We have 5,000 products. Now what? Then the question stops being infinite scroll and becomes search plus facets: a filter that gets a visitor to a page of 20 relevant items beats any scrolling strategy over 5,000 irrelevant ones. Storefront search without a search service covers the client-side end of that.

Templates in this post

ASoc Apex Admin is a large multi-purpose admin — 5 dashboards across 115+ pages, a full ecommerce back office, workspace apps and a deep component showcase including advanced datatables, which is where paging and virtualization decisions actually land. ASoc Clover Admin is CRM-focused: Sales and Finance dashboards, team management, customers, deals, email, chat and calendar. ASoc Crest Admin is a classic sidebar admin with 5 dashboards, 8 app modules and a full component library, dark mode throughout.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates.

Keep reading

Tutorial8 min read

Next.js Intercepting Routes: Why This Codebase Has Zero

Intercepting routes mask navigation to a route in your own app. This storefront's live-preview modal shows a cross-origin iframe instead — a real reason the pattern never fit here.

Read more