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.
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:
| Measurement | Value |
|---|---|
| Cards rendered | 111 |
| Prerendered HTML | 485,440 bytes |
| Same file, gzipped | 34,658 bytes |
| Average markup per card | ~3.5 KB raw |
<img> elements | 113 |
…of which loading="lazy" | 110 |
| Internal links in the document | 256 (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:
- 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.
rootMargin: "600px"starts the fetch before the sentinel is visible, which is the difference between "seamless" and "spinner at the bottom of every screen".useTransitionplus thependingguard stops the double-fire that happens when the observer re-triggers while a request is in flight.history.replaceStatekeeps 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
| Mistake | How it shows up | Fix |
|---|---|---|
| Adding infinite scroll to a bounded, indexable list | Fewer crawlable links; pages drift into Discovered — currently not indexed | Prerender the whole list; measure the gzipped page first |
| Quoting the uncompressed HTML size | A "474 KB page" that is 34.7 KB on the wire | Compare gzipped bytes against the JS the scroller adds |
| No cursor in the URL | Back returns to the top with 80 items lost | history.replaceState on each load; restore on mount |
| Sentinel with no button | Unreachable by keyboard, invisible to screen readers | A real <button>, with the observer as an enhancement |
| No in-flight guard | Two or three pages load per intersection; duplicate keys | pending check plus a stable key per item |
| Footer below an unbounded list | Contact, licence and legal links become unreachable | Keep the footer out of the scroll container, or paginate |
| Leaving the first card lazy | LCP element starts loading late — ~1.7 s in our case | priority 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.
