Pagination at build time: why a static site should not page with a query string

The post list is sliced into static pages during the build, so page N is a real route. No client-side paging logic, no query string, just links.

The blog list here shows six posts per page. Page two lives at /zh/blog/page/2/, a real HTML file produced at build time, not /zh/blog/?page=2.

Why not a query string

On static hosting, ?page=2 is the same HTML to the server and to a crawler. Paged content then either has to be sliced in the browser, which means shipping every post to the first paint and giving the performance advantage back, or the server has to read the query string, which stops being static hosting. Building the pages up front sidesteps both.

How page numbers converge

Slicing and clamping live in lib/paginate.ts as pure logic with zero imports:

export function paginate(items, page, size) {
  const total = totalPages(items.length, size);
  const current = clampPage(page, total);
  const start = (current - 1) * step;
  return {
    items: items.slice(start, start + step),
    page: current,
    totalPages: total,
    hasPrev: current > 1,
    hasNext: current < total,
  };
}

A page number past the end clamps to the last page instead of returning an empty page, and an invalid size is treated as one page so nothing divides by zero. Both branches are covered by assertions.

Routes derived from the post count

blogPagedPaths in server/paths.ts computes how many pages to emit. Page one is the list page itself and is not generated twice. Add a post and the new page appears without hand-written routes.

The component renders only when there are multiple pages

With a single page, pagination is null and the component does not render at all, so the footer never grows a lone “1”.

The price

One HTML file per page, so page count grows linearly with the archive, and paging is a full navigation that preserves no filter state. This site has no filters, so the second point costs nothing.

← Back to all posts

Comments

…