Next.js Pagination: The Threshold This Blog Blew Past by 3x
This codebase paginates nothing — /blog still renders 106 posts on one page, 3.5x past its own stated 30-post threshold. The searchParams pattern for when it's real.
This codebase paginates nothing. /templates renders all 111 products into one client-filtered grid, and /blog renders every post on one page — with a comment in the component that says to revisit that decision "past ~30 posts." It now carries 106. Neither number is a bug; both are a decision this article has to earn before recommending you undo it.
The three shapes, and what each one costs
| Approach | Where state lives | URL reflects position? | Server load per page view |
|---|---|---|---|
Client-side slice (array.slice(start, end) over useState) | Browser, lost on refresh | No — same URL for every page | One full payload, sliced in the browser |
searchParams-driven pagination | The URL (?page=2) | Yes — bookmarkable, back-button correct | One page's worth per request |
| Infinite scroll | Browser, accumulates | No, unless synced to the URL separately | Compounds — each scroll fetches more on top of what's loaded |
The infinite-scroll post already covers the third row's tradeoff in full — an IntersectionObserver plus a Server Action, and the honest caveat that it's the wrong tool below a few dozen items. This post is about the second row, searchParams-driven pagination, which is the one Next.js actually has a primitive for: a Server Component reads page straight off searchParams, no client state, no effect, no library.
The audit: why this codebase hasn't needed it
// src/components/organisms/BlogIndex.tsx
/**
* Organism: the `/blog` listing — header plus the full post grid.
*
* Deliberately unpaginated. Pagination only earns its complexity (and its
* `rel=next/prev` + canonical bookkeeping) once the archive is long enough to
* hurt; splitting six posts across pages would dilute the index's internal
* link equity for no reader benefit. Revisit past ~30 posts.
*/
export default function BlogIndex() {
const posts = getAllPosts();
// ...
}
That comment was written when /blog had six posts. It has 106 now — 3.5x past its own stated revisit threshold — and the page still renders every one of them in a single grid with no rel="next"/rel="prev", no page-2 URL, and no client JS spent tracking a page number, because BlogIndex isn't a Client Component at all. /templates makes the same call for a different reason: 111 products is small enough that shipping the whole catalog and filtering client-side (via filterTemplates, already covered for the collection-narrowing case) costs less than the request-per-page-turn tax pagination would add, and the chip filters already give a reader a faster way to narrow 111 down than paging through them ever would.
Neither omission is an oversight. But the blog's own comment names a threshold, in writing, that this build has now blown past by more than 3x — which is the actual news in this audit, not a hypothetical.
What Next.js pagination looks like when the threshold is real
No route in this codebase needs this yet, so the following is written for the article rather than extracted from a live route — same convention as the calendar-grid post. The primitive is a Server Component reading searchParams, slicing a known-length array server-side, and building page into every link so the URL is the only state that exists:
// app/blog/page.tsx (illustrative — not what BlogIndex.tsx does today)
const PAGE_SIZE = 20;
export default async function BlogPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>;
}) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const all = getAllPosts();
const totalPages = Math.ceil(all.length / PAGE_SIZE);
const slice = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<>
<PostGrid posts={slice} />
<nav aria-label="Pagination">
{page > 1 && <Link href={`/blog?page=${page - 1}`}>Previous</Link>}
{page < totalPages && (
<Link href={`/blog?page=${page + 1}`}>Next</Link>
)}
</nav>
</>
);
}
Three details carry the whole pattern. First, page is clamped with Math.max(1, ...) — an unvalidated ?page=-3 or ?page=abc must not produce a negative slice() start, which silently returns an empty array rather than erroring, the kind of bug that ships quietly. Second, the links are real <a>-backed Link components pointing at real URLs, not onClick handlers mutating state — that's what makes page 2 bookmarkable, back-button-correct, and crawlable, which a useState-based pager never is. Third, nothing here is a Client Component: the whole page, including the "Previous"/"Next" nav, renders server-side, because reading a searchParams value and slicing an array needs no browser APIs at all.
The accessibility bookkeeping a pager needs, too
The nav in the snippet above is deliberately minimal, and a real implementation owes it three more things that are easy to skip and cheap to add:
aria-current="page"on the link to the page currently being viewed — a screen reader user tabbing through the nav otherwise has no way to tell which page they're already on.- A visible page indicator ("Page 2 of 6"), not just Previous/Next — a reader who lands on page 4 via a bookmark or a search result has no context for how far into the archive they are without one.
<nav aria-label="Pagination">, already in the snippet, so the region is announced as a landmark distinct from any other navigation on the page — this codebase's header nav and footer nav both carry their own labels for the same reason.
None of that is unique to Next.js — it's the same checklist any paginated list owes regardless of framework — but it's worth stating explicitly here because a client-useState pager tends to skip it by omission: without a URL to anchor "page 2" to, there's less pressure to give the state a visible, announced identity at all.
The SEO bookkeeping a client-side pager skips entirely
The moment pagination reflects in the URL, three more things become the implementer's job, all invisible in a client-useState version because there's only ever one URL to think about:
rel="next"/rel="prev"(or, per current Google guidance, just solid internal linking between pages) so crawlers can walk the sequence.- A canonical per page, not one canonical pointing every page back to page 1 — that would tell Google page 2's content doesn't exist anywhere indexable.
generateMetadatareading the samesearchParams, so<title>and the meta description reflect which page is being served instead of repeating page 1's copy 6 times over.
Skip any of the three and you get exactly the failure mode the pSEO post already measured: pages that render fine for a human and never accumulate the link signals a crawler uses to decide they're worth indexing.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Pagination state in useState, not the URL | Page 2 isn't bookmarkable; refresh always returns to page 1 | Read/write page via searchParams, not component state |
No bound on page | ?page=-1 or ?page=99999 renders empty or throws | Clamp with Math.max/Math.min against a known totalPages |
| One canonical for every page | Google treats pages 2+ as duplicates of page 1, drops them from the index | Canonical per page: ?page=2 canonicalizes to itself |
| Pagination added below the threshold that justifies it | Extra request-per-page-turn latency and rel/canonical bookkeeping for a list a reader could see whole in one scroll | Revisit at a stated number (this codebase's own comment says ~30) and hold the line until then |
Client-fetching each page with fetch() in a useEffect | Ships a loading spinner and a waterfall for content Next.js could have rendered server-side in the first response | Read searchParams in a Server Component; no client fetch needed |
Frequently asked questions
Why not just paginate everything by default?
Because pagination isn't free — it adds a request per page turn, rel/canonical bookkeeping, and a page-count calculation to maintain, for a benefit that only exists once a single page is genuinely too large to render or too slow to scan. Below that line it's pure overhead, which is why this codebase's own /blog comment names a specific number rather than pagination-by-default.
Does searchParams pagination work with static export?
Only if every page value is enumerated in generateStaticParams-equivalent config ahead of time — searchParams values aren't known at build time the way route segments are, so a fully static build needs either a bounded, known page count or to render the paginated route dynamically.
Should /blog actually get pagination now that it's past 30 posts?
That's the honest answer this audit doesn't get to skip: the comment's threshold is a stated trigger, and 106 is past it. It hasn't caused a measured problem yet — no Search Console signal or Lighthouse regression tied to it — but that's a "hasn't been checked," not a "checked and fine." It's the one open item this post surfaces rather than closes.
Is infinite scroll ever the right replacement instead of numbered pages?
For a feed a reader browses rather than searches, yes — the infinite-scroll post covers that case. For an index a reader might want to link to a specific page of, or that needs rel/canonical bookkeeping per page for SEO, numbered searchParams pagination is the one that keeps every page addressable.
Templates where this ships
ASoc Vertex is a sales-analytics dashboard with large tabular views that outgrow a single unpaginated page fast. ASoc Apex ships a full admin UI kit including table patterns built for this. ASoc Clover is a CRM dashboard where customer and deal lists are exactly the kind of list this post's pattern is for.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the URL-driven filtering this pattern pairs with, read collection filtering with searchParams; for the alternative shape, read infinite scroll in Next.js.
