DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on Originally published at sourcier.uk

Paginating a Blog in Astro

Original post: Paginating a Blog in Astro

Series: Part of How this blog was built — documenting every decision that shaped this site.

Most blog tutorials reach for paginate() — Astro's built-in helper — and call it done. It works, but the URLs it produces (/blog/2, /blog/3) aren't great for SEO. Search engines prefer a clear signal that these are pages of a list, not individual resources. /blog/page/2 communicates that explicitly.

This post walks through how I built pagination on this site: clean URLs, no client-side JavaScript, placeholder cards, and a reusable BlogGrid component.

The URL structure

The target structure:

/blog           → page 1 (canonical)
/blog/page/2    → page 2
/blog/page/3    → page 3
Enter fullscreen mode Exit fullscreen mode

Page 1 lives at /blog with no page number — it's the canonical listing URL and the one that gets linked to from everywhere. Pages 2 and beyond use /blog/page/N to signal clearly that they are paginated continuations.

I deliberately avoided /blog/1 for page 1. A URL like /blog/1 implies there's a post with the ID 1, or that the canonical URL is somehow different from /blog. Neither is true.

Why not paginate()?

Astro's paginate() is great for getting started. You export getStaticPaths from a [...page].astro file, pass your content collection and a page size, and Astro generates all the pages. The catch is the URL pattern it produces:

/blog           → page 1
/blog/2         → page 2
/blog/3         → page 3
Enter fullscreen mode Exit fullscreen mode

That flat structure is fine functionally, but /blog/page/2 is more semantically correct and is the pattern recommended by Google for paginated content. To get it with Astro you need to take back control of routing.

Two route files instead of one

Rather than fighting paginate(), I use two separate route files:

src/pages/blog/index.astro handles page 1. It's a static page — no getStaticPaths needed — that fetches all posts, slices the first page, and renders the grid.

src/pages/blog/page/[page].astro handles pages 2 and above. It uses getStaticPaths to generate one static page per remaining page number.

// src/pages/blog/page/[page].astro
export async function getStaticPaths() {
  const PAGE_SIZE = 9;
  const allPosts = (await getCollection("posts"))
    .filter(isPublished)
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
  return Array.from({ length: totalPages - 1 }, (_, i) => ({
    params: { page: String(i + 2) },
  }));
}
Enter fullscreen mode Exit fullscreen mode

The length: totalPages - 1 is key — page 1 is handled by index.astro, so this file only generates pages 2 through N. Attempting to generate page 1 here would create a conflicting route at /blog/page/1.

isPublished is the same helper from the scheduled publishing post — it filters out drafts and future-dated posts outside of dev mode, so archive pages never leak unpublished content.

Notice that getStaticPaths only returns params, not props. The actual page data — the slice of posts, currentPage, prevUrl, nextUrl — is recomputed in the component script below, using Astro.params.page:

const PAGE_SIZE = 9;
const currentPage = Number(Astro.params.page);

const allPosts = (await getCollection("posts"))
  .filter(isPublished)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
const posts = allPosts.slice(
  (currentPage - 1) * PAGE_SIZE,
  currentPage * PAGE_SIZE,
);
const prevUrl = currentPage === 2 ? "/blog" : `/blog/page/${currentPage - 1}`;
const nextUrl =
  currentPage < totalPages ? `/blog/page/${currentPage + 1}` : null;
Enter fullscreen mode Exit fullscreen mode

That means the same fetch-sort-slice logic is duplicated three times across index.astro, getStaticPaths, and the component script in [page].astro. It's a small DRY violation, but getStaticPaths runs in an isolated build-time context before the rest of the file executes, so there's no way to share the computed allPosts array between it and the component body without a separate module-level helper. For three call sites doing one cheap array operation, the duplication was the simpler trade-off.

A shared BlogGrid component

Both route files render the same grid. Rather than duplicating the markup, I extracted it into src/components/BlogGrid.astro, which accepts these props:

interface Props {
  posts: CollectionEntry<"posts">[];
  currentPage: number;
  totalPages: number;
  prevUrl: string | null;
  nextUrl: string | null;
  paginationBase?: string;
  sectionLabel?: string;
  sectionTitle?: string;
  sectionDescription?: string;
}
Enter fullscreen mode Exit fullscreen mode

It renders the post grid, placeholder cards, and pagination controls. Both index.astro and [page].astro import it and pass their data through, along with a section label and heading so the same component reads correctly whether it's showing "Recent articles" on page 1 or "Posts on page 2" further into the archive. paginationBase is what lets the tag system reuse this exact component for paginated tag pages — it swaps /blog/page/N for /tags/<tag>/N without any other change.

Placeholder cards for a half-full grid

While the post count is low, a 9-post grid with only 4 real posts on the last page would look half-empty. Placeholder cards fill the remaining slots instead of leaving obvious gaps.

Blog grid wireframe showing two full rows of cards and a third row with two real cards and one placeholder card, plus pagination controls below

Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/pagination-in-astro

Click the expand icon to view it fullscreen.

The grid is responsive — two columns on tablet, three on desktop — so a placeholder count that's correct for one breakpoint can be wrong for the other. BlogGrid.astro computes both independently, then works out how many placeholders are needed at both breakpoints, only one, or neither:

const showGhostCards = currentPage === totalPages;
const TABLET_COLS = 2;
const DESKTOP_COLS = 3;
const tabletGhostCount = showGhostCards
  ? (TABLET_COLS - (posts.length % TABLET_COLS)) % TABLET_COLS
  : 0;
const desktopGhostCount = showGhostCards
  ? (DESKTOP_COLS - (posts.length % DESKTOP_COLS)) % DESKTOP_COLS
  : 0;
const sharedGhostCount = Math.min(tabletGhostCount, desktopGhostCount);
const tabletOnlyGhostCount = tabletGhostCount - sharedGhostCount;
const desktopOnlyGhostCount = desktopGhostCount - sharedGhostCount;
Enter fullscreen mode Exit fullscreen mode

Only the last page ever gets placeholders — every other page is a full row at both breakpoints. The three resulting counts each render into a differently-classed cell (--tablet-up, --tablet-only, --desktop-only) so CSS media queries can show or hide the right ones without any JavaScript recalculating the layout on resize.

I deliberately kept BlogCardPlaceholder.astro static rather than reaching for an animated loading skeleton. These cards aren't hiding content that's still loading — the grid is fully rendered at build time, there's nothing to wait for. A shimmering skeleton would be actively misleading here, so the placeholder is just a dashed-border card with a quiet "More posts coming soon" label:

<div class="card card__blog card__blog--placeholder" aria-hidden="true">
  <div class="card__blog--placeholder-inner">
    <span class="card__blog--placeholder-label">More posts coming soon</span>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

aria-hidden="true" and pointer-events: none keep it invisible to screen readers and non-interactive, since there's nothing behind it to click or announce.

Pagination controls

The navigation sits below the grid and only renders when there's more than one page. Previous and next links use the prevUrl/nextUrl props passed in from the route files. Page number buttons are generated from totalPages, routed through a small pageUrl() helper so the same markup works for both /blog/page/N and a tag page's paginationBase:

function pageUrl(n: number) {
  if (paginationBase) {
    return n === 1 ? paginationBase : `${paginationBase}/${n}`;
  }
  return n === 1 ? "/blog" : `/blog/page/${n}`;
}
Enter fullscreen mode Exit fullscreen mode
{Array.from({ length: totalPages }).map((_, i) => {
  const n = i + 1;
  return (
    <a href={pageUrl(n)} aria-current={n === currentPage ? "page" : undefined}>
      {n}
    </a>
  );
})}
Enter fullscreen mode Exit fullscreen mode

The n === 1 branch ensures the first page button always links to /blog (or the tag's base URL), never /blog/page/1.

Disabled states on Previous/Next use aria-disabled and pointer-events: none rather than swapping <a> for <span>, which keeps the DOM structure consistent across all pages.

What this produces

At build time, Astro generates:

dist/blog/index.html        ← page 1
dist/blog/page/2/index.html ← page 2 (when posts > 9)
dist/blog/page/3/index.html ← page 3 (when posts > 18)
Enter fullscreen mode Exit fullscreen mode

Everything is static HTML. No client-side JavaScript, no API calls, no hydration — the pagination just works as links between pre-rendered pages.

As more posts are published, the grid fills naturally. Once 10 posts exist, page 2 appears automatically at the next build.

Full code listing

---
import { getCollection } from "astro:content";
import { isPublished } from "../../../utils/drafts";
import BaseLayout from "../../../layouts/BaseLayout.astro";
import PageHero from "../../../components/PageHero.astro";
import MailingListCTA from "../../../components/MailingListCTA.astro";
import BlogGrid from "../../../components/BlogGrid.astro";
import BlogTagCloud from "../../../components/BlogTagCloud.astro";

export async function getStaticPaths() {
  const PAGE_SIZE = 9;
  const allPosts = (await getCollection("posts"))
    .filter(isPublished)
    .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
  const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
  return Array.from({ length: totalPages - 1 }, (_, i) => ({
    params: { page: String(i + 2) },
  }));
}

const PAGE_SIZE = 9;
const currentPage = Number(Astro.params.page);

const allPosts = (await getCollection("posts"))
  .filter(isPublished)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());

const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
const posts = allPosts.slice(
  (currentPage - 1) * PAGE_SIZE,
  currentPage * PAGE_SIZE,
);
const prevUrl = currentPage === 2 ? "/blog" : `/blog/page/${currentPage - 1}`;
const nextUrl =
  currentPage < totalPages ? `/blog/page/${currentPage + 1}` : null;
---

<BaseLayout pageTitle={`Blog — Page ${currentPage} — Sourcier`}>
  <PageHero kicker="Writing" title="Blog" subtitle="..." />

  <BlogGrid
    posts={posts}
    currentPage={currentPage}
    totalPages={totalPages}
    prevUrl={prevUrl}
    nextUrl={nextUrl}
    sectionLabel="Archive"
    sectionTitle={`Posts on page ${currentPage}`}
    sectionDescription={`Page ${currentPage} of ${totalPages} from the blog archive.`}
  />

  <BlogTagCloud />
  <MailingListCTA />
</BaseLayout>
Enter fullscreen mode Exit fullscreen mode
---
import type { CollectionEntry } from "astro:content";
import BlogPost from "./BlogPost.astro";
import BlogCardPlaceholder from "./BlogCardPlaceholder.astro";
import readingTime from "reading-time";

interface Props {
  posts: CollectionEntry<"posts">[];
  currentPage: number;
  totalPages: number;
  prevUrl: string | null;
  nextUrl: string | null;
  paginationBase?: string;
  sectionLabel?: string;
  sectionTitle?: string;
  sectionDescription?: string;
}

const { posts, currentPage, totalPages, prevUrl, nextUrl, paginationBase } =
  Astro.props;

const showGhostCards = currentPage === totalPages;
const TABLET_COLS = 2;
const DESKTOP_COLS = 3;
const tabletGhostCount = showGhostCards
  ? (TABLET_COLS - (posts.length % TABLET_COLS)) % TABLET_COLS
  : 0;
const desktopGhostCount = showGhostCards
  ? (DESKTOP_COLS - (posts.length % DESKTOP_COLS)) % DESKTOP_COLS
  : 0;
const sharedGhostCount = Math.min(tabletGhostCount, desktopGhostCount);
const tabletOnlyGhostCount = tabletGhostCount - sharedGhostCount;
const desktopOnlyGhostCount = desktopGhostCount - sharedGhostCount;

function pageUrl(n: number) {
  if (paginationBase) {
    return n === 1 ? paginationBase : `${paginationBase}/${n}`;
  }
  return n === 1 ? "/blog" : `/blog/page/${n}`;
}
---

<section class="section blog-grid-section flow-section">
  <div class="container is-max-desktop">
    {posts.map((post) => (
      <div class="blog-grid__cell">
        <BlogPost
          title={post.data.title}
          url={`/blog/${post.id}`}
          cover={post.data.cover}
          pubDate={post.data.pubDate}
          draft={post.data.draft}
          readingTime={readingTime(post.body ?? "").text}
        />
      </div>
    ))}
    {Array.from({ length: sharedGhostCount }).map(() => (
      <div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--tablet-up">
        <BlogCardPlaceholder />
      </div>
    ))}
    {Array.from({ length: tabletOnlyGhostCount }).map(() => (
      <div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--tablet-only">
        <BlogCardPlaceholder />
      </div>
    ))}
    {Array.from({ length: desktopOnlyGhostCount }).map(() => (
      <div class="blog-grid__cell blog-grid__ghost-cell blog-grid__ghost-cell--desktop-only">
        <BlogCardPlaceholder />
      </div>
    ))}

    {totalPages > 1 && (
      <nav class="pagination-nav" aria-label="Blog pagination">
        <a
          href={prevUrl}
          aria-disabled={!prevUrl}
          tabindex={!prevUrl ? -1 : 0}
        >
          &larr; Previous
        </a>
        <div class="pagination-nav__pages" role="list">
          {Array.from({ length: totalPages }).map((_, i) => {
            const n = i + 1;
            return (
              <a
                href={pageUrl(n)}
                role="listitem"
                aria-current={n === currentPage ? "page" : undefined}
              >
                {n}
              </a>
            );
          })}
        </div>
        <a href={nextUrl} aria-disabled={!nextUrl} tabindex={!nextUrl ? -1 : 0}>
          Next &rarr;
        </a>
      </nav>
    )}
  </div>
</section>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)