Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

ApproachWhere state livesURL reflects position?Server load per page view
Client-side slice (array.slice(start, end) over useState)Browser, lost on refreshNo — same URL for every pageOne full payload, sliced in the browser
searchParams-driven paginationThe URL (?page=2)Yes — bookmarkable, back-button correctOne page's worth per request
Infinite scrollBrowser, accumulatesNo, unless synced to the URL separatelyCompounds — 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.
  • generateMetadata reading the same searchParams, 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

MistakeSymptomFix
Pagination state in useState, not the URLPage 2 isn't bookmarkable; refresh always returns to page 1Read/write page via searchParams, not component state
No bound on page?page=-1 or ?page=99999 renders empty or throwsClamp with Math.max/Math.min against a known totalPages
One canonical for every pageGoogle treats pages 2+ as duplicates of page 1, drops them from the indexCanonical per page: ?page=2 canonicalizes to itself
Pagination added below the threshold that justifies itExtra request-per-page-turn latency and rel/canonical bookkeeping for a list a reader could see whole in one scrollRevisit 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 useEffectShips a loading spinner and a waterfall for content Next.js could have rendered server-side in the first responseRead 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.

Keep reading

Tutorial10 min read

Product Filtering in Next.js: Why Filters Belong in the URL

Filters in useState cannot be shared, bookmarked, or server-rendered. How to read them from searchParams — and which filtered URLs to let Google crawl.

Read more
Tutorial11 min read

A Product Image Gallery in Next.js That Google Can Actually See

Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.

Read more
Tutorial9 min read

Product Schema in Next.js: JSON-LD That Earns Rich Results

Product needs only one of offers, review or aggregateRating — so a truthful offer qualifies you without inventing reviews. The shape, the validation, the manual-action trap.

Read more