Medium wave · 540–1600 kHz58 stn
1580kHz

Page 2 Changes Every Time You Publish

A towering brass card-catalog wall where dropping a single glowing card into the top slot forces every drawer to slide one position sideways, brass pointer arms swinging to indexes that no longer match, dark steampunk archive lit by neon amber and teal filaments in drifting fog

You publish one post. Your build emits one new page, updates the homepage, updates the RSS feed — and quietly rewrites every paginated archive page on the site.

/blog/2/ no longer contains what it contained yesterday. Neither does /blog/3/, or /blog/7/. Someone who bookmarked page 3 because it had the post they wanted now gets ten different posts. Nothing errored. Nothing warned you. The diff in dist/ is enormous and you never looked at it.

This is not a bug in your framework. It is what offset pagination is, and on a static site it costs more than you think.


The mechanism

Offset pagination slices an ordered list into fixed-size windows. Page N is items N * size through (N + 1) * size of a collection sorted newest-first.

That last part is the problem. The contents of page N are not a function of N. They are a function of the entire collection, and the collection is ordered so that new items arrive at the front.

// src/pages/blog/[...page].astro — the standard shape
export async function getStaticPaths({ paginate }) {
  const posts = (await getCollection('posts')).sort(
    (a, b) => b.data.publishedAt - a.data.publishedAt
  );

  return paginate(posts, { pageSize: 10 });
}

Prepend one item to a sorted array and every element shifts by one index. With pageSize: 10, publishing a single post moves the boundary of every page on the site. Page 2 loses its last post to page 3, which loses its last post to page 4, all the way down to the tail.

The URL is stable. The content behind it is not. That is the exact inverse of what a URL is supposed to guarantee.


What it actually breaks

Inbound links rot silently. Post permalinks are stable — that is the entire reason we fuss over slugs. Paginated archive URLs look equally permanent and are not. Anyone who links /blog/4/ is linking to an offset, not to content. Six posts later it points somewhere else. There is no 404 to alert you and no redirect to write. It just quietly means something different.

Your cache strategy stops working. The argument for static hosting is that HTML generated at build time can be cached hard and invalidated on deploy. Post pages genuinely never change, so long TTLs are safe. Paginated archives change on every single publish, so every one of them has to be purged on every deploy. If your CDN does per-path purging, you are purging N paths to publish one post. If you were counting on ETag revalidation to make that cheap, that saving evaporates too — the bytes really are different.

The tail page is unstable in a worse way. Crossing a page-size boundary does not just change content, it changes the route set. Post 61 on a ten-per-page site creates /blog/7/. Unpublishing a post deletes it again — and a deleted static route is a hard 404 with no redirect target, because there is no defensible destination for a page that was only ever an offset.

Crawlers re-fetch everything. Google dropped rel="next" and rel="prev" as an indexing signal in 2019; paginated pages are now treated as ordinary pages that happen to link to each other. Ordinary pages whose content changes on every deploy get re-crawled on every deploy. On a 60-post blog nobody cares. On a 2,000-post archive you are spending crawl budget re-reading the same posts in reshuffled groupings.

Your build output stops being reviewable. A one-post publish should produce a small, legible diff. If you have ever tried to sanity-check what a deploy actually changed and given up, paginated archives are why the answer is always “everything.”


Measure it before you argue about it

Build, add a post, build again, and count.

npm run build && cp -r dist /tmp/before
# add one post to content/posts/
npm run build

diff -rq /tmp/before dist | wc -l
diff -rq /tmp/before dist | grep -c '/blog/'

The first number is how many files a one-post change touches. The second is how much of that is pagination churn. On most content sites the ratio is embarrassing: a handful of genuinely new files, and a long tail of archive pages whose only change is which ten posts they happen to contain today.


Cut from the stable end

The fix follows directly from the mechanism. Pages shift because new items enter at the same end the slicing starts from. So slice from the end that does not move.

Order ascending and only the last page changes. If page 1 is the oldest ten posts, it is frozen forever — appending never shifts an earlier index, and only the final, partially-filled page churns. That is the correct underlying model. It is also a miserable reading experience, because your newest writing ends up on the last page.

Shard by time instead. Year and month archives are the practical form of the same idea:

/archive/2024/     ← frozen, cache for a year
/archive/2025/     ← frozen, cache for a year
/archive/2026/     ← changes only while 2026 is current

Every URL now means one fixed, human-comprehensible thing. /archive/2024/ is “everything I wrote in 2024” — permanently true, safe to link, safe to cache hard, and it never renumbers. Exactly one route is volatile at any time. Category and tag archives shard the same way on a different axis.

Keep one volatile feed, deliberately. A homepage or /blog/ showing the latest ten posts should change on every publish — that is its job. Make it the only page with that property, cache it short, and treat it as the entry point rather than the archive.

Cache-Control: public, max-age=0, must-revalidate   # /blog/ — the feed
Cache-Control: public, max-age=31536000, immutable  # /archive/2024/ — frozen
Cache-Control: public, max-age=31536000, immutable  # /posts/<slug>/ — frozen

Or do not paginate at all. Under a few hundred posts, a single archive page of titles and dates is a small HTML document that gzips extremely well and needs no JavaScript to filter. Pagination on a 90-post blog is cargo-culted from CMSs that paginated because they were hitting a database on every request. You are not. Check the actual transfer size of the un-paginated page before assuming you need to split it.


If you keep offset pages anyway

Sometimes you inherit them, or the archive genuinely is too large for one page. Then treat them honestly as navigation, not addresses:

  • Keep them out of sitemap.xml. A sitemap entry whose lastmod changes on every deploy is noise you are paying to serve.
  • Make sure every post is reachable from at least one stable URL — a year archive, a tag page, or search. Offset pages should never be the only path to a piece of content.
  • Give them short cache TTLs, and stop describing your site as having an all-immutable cache policy. It does not.
  • Never link to them from anywhere permanent: not from post bodies, not from a newsletter, not from documentation.

The takeaway

A static site’s whole value proposition is that a URL maps to a file, and that file does not change until you change it. Offset pagination breaks that property on purpose, for every archive page, on every publish — in exchange for a UI convention inherited from database-backed systems that actually needed it.

Page numbers are an implementation detail leaking into your URL space. Dates and categories are not. Shard on something that means something, and the churn disappears.