Blog & RSS
The blog is three routes over two collections, plus a feed. /blog/ paginates the archive, /blog/<slug>/ renders a post, /blog/category/<slug>/ filters it, and /rss.xml serialises it. Eight sample posts, four authors and five categories ship.
Every query the three routes share lives in src/js/blog.ts. A route never calls getCollection and sorts inline.
The read layer
export const blogHref = headerHref("Blog"); // "/blog/" — from the IA, never spelled out
export const POSTS_PER_PAGE = 6;
export async function getPosts(): Promise<Post[]> // published, newest first
export function postCategories(posts): BlogCategory[] // the filter rows, with counts
export function postsInCategory(posts, slug): Post[]
export function categoryHref(label: string): string
export function postHref(post: Post): string
export function relatedPosts(posts, current, limit = 3): Post[]
export function readingTime(post: Post): number
export async function authorsOf(post): Promise<Author[]>
export async function withAuthors(posts): Promise<PostWithAuthors[]>
export function pagerMeta(page): { titleSuffix, descriptionPrefix }
POSTS_PER_PAGE is 6 because that is two full rows of the three-across grid at lg, which is what makes the last row of a full page complete rather than ragged. It is shared by the index and the category routes so the two cannot page differently.
Pagination
/blog/[...page].astro uses a rest parameter rather than [page], because Astro’s paginate() puts page 1 at the bare route:
/blog/ → page 1
/blog/2/ → page 2
That is also why PostGrid builds its own hrefs instead of using page.url.prev and page.url.next — the pager needs arbitrary page numbers, not just neighbours.
Which numbers it shows is a pure function, paginationWindow in src/components/ui/pagination/window.ts, and it is worth knowing its three properties:
paginationWindow(1, 27); // [1, 2, 3, 4, 5, "ellipsis", 27]
paginationWindow(14, 27); // [1, "ellipsis", 12, 13, 14, 15, 16, "ellipsis", 27]
The window is a fixed width, not a fixed radius — a radius shrinks the pager at the ends, where shifting a constant-size run keeps it the same size throughout. A gap of exactly one page renders that page rather than an ellipsis, because an ellipsis standing in for a single hidden number is the same width with less information and one fewer link. And current is clamped, so a hand-typed /blog/99/ cannot produce a pager with no anchor.
That module is dependency-free with window.test.ts beside it, because an off-by-one there shows up as a pager that quietly stops linking to the last page — exactly the kind of bug a build never catches.
Every pager page gets its own title and description
Page 2 of an archive sharing page 1’s meta description is a duplicate to a crawler. pagerMeta owns the marker so the index and the category route cannot mark their pages differently:
// page 1 → { titleSuffix: "", descriptionPrefix: "" }
// page 2 → { titleSuffix: " — page 2", descriptionPrefix: "Page 2 of 5. " }
A prefix for the description rather than a suffix, because the base copy ends in a full stop — “Page 2 of 5. Field notes from…” reads, where “Field notes from…. — page 2” does not. The title takes a suffix and no “of N”, because a total that shifts with the post count would churn every title.
This is the gap most themes ship with. Develi closes it, and the shape is worth copying if you add another paginated section.
Categories
Categories are plain strings in frontmatter, folded into filter rows at build time:
export function postCategories(posts: readonly Post[]): BlogCategory[]
// → [{ label: "Studio Notes", slug: "studio-notes", href: "/blog/category/studio-notes/", count: 3 }, …]
Keyed on the slug, not the label, because the slug is what the route matches — otherwise “AI & Automation” and “AI and automation” become two rows pointing at one page. First label seen wins, which makes your entry files the source of the display casing. Sorted alphabetically by label.
categoryHref is a total function — it takes any label and returns a path:
categoryHref("AI & Automation"); // "/blog/category/ai-automation/"
postCategories builds its own href by calling it too, so a badge and a filter chip pointing at the same category cannot disagree. The post page previously found this by folding the whole archive and guarding the result with ?? "/blog/" — a fallback that could never fire, sitting in front of a hardcoded literal. Both were the failure blogHref exists to prevent.
The filter is links, not JavaScript
/blog/category/<slug>/ builds one static page per category, and the chip row is a set of <a> elements. A static site can build these pages, and a link to a real URL is cheaper, more robust and shareable in a way a client-side filtered view is not.
The chip row is folded from every published post, not from the current category’s slice, so the filter still offers the whole taxonomy while you are inside one branch of it. The category route reuses PostGrid unchanged — the only differences are which posts arrive, which chip is lit and what the masthead says, and all three are props.
Authors
authors is an array of references. They are resolved in the route, not in the card, and that is a structural constraint rather than a style choice: Astro markup cannot await, so a card that fetched its own author would have to be an island. One Promise.all per rail instead:
const [gridPosts, featured, archive] = await Promise.all([
withAuthors(page.data),
withAuthors(allPosts.slice(0, 4)),
withAuthors(allPosts.slice(-3).reverse()),
]);
Cards take authors[0] because the card design draws one byline; the post page lists them all. Where an author has no avatar, components fall back to initials via initials() in @js/textUtils.
The index page’s two rails
Beyond the paginated grid, /blog/ draws a featured block (the four newest) and an archive rail (the three oldest, reversed). Both are page 1 only:
const isFirstPage = page.currentPage === 1;
A curated “latest” block repeated above page 7 of an archive is stating something false, and the pager’s own position already tells the reader where they are.
The post page
/blog/<slug>/ is where the theme’s two SEO mechanisms meet, and it is the only route that uses both:
const article = getArticleSchema({
headline: title,
description,
url: canonical,
image: imageUrl,
datePublished: pubDate.toISOString(),
dateModified: updatedDate?.toISOString(),
authorName: byline?.data.name ?? "",
authorUrl: byline?.data.authorLink,
inLanguage: siteLocale,
publisherId: organizationId(canonical),
});
---
<BaseLayout
title={`${title} — Develi`}
description={description}
image={heroImage}
schema={[article]}
article={{ published: pubDate, modified: updatedDate, author: byline?.data.name }}
>
schema produces the BlogPosting node in the JSON-LD graph; the article prop produces og:type=article plus article:published_time and article:modified_time. Both derive from the same entry, so they cannot disagree.
publisherId ties the post to the Organization node BaseHead already emits, rather than describing a second unlinked publisher. canonical is derived exactly the way BaseHead derives its own, so the JSON-LD url and the <link rel="canonical"> always match.
Related posts
Same category first, newest first, then topped up from the rest of the archive so the rail is never short:
export function relatedPosts(posts, current, limit = 3): Post[]
Topping up matters more than it sounds. A post that is the only one in its category would otherwise end with an empty section, which reads as a broken page rather than an editorial choice.
Reading time
export function readingTime(post: Post): number // whole minutes, at least 1
200 words per minute, rounded up, never zero. The point of the number is to set an expectation rather than to be accurate. body is optional on a content-layer entry, so a post with no body yields 1 rather than throwing.
RSS
/rss.xml is a hand-rolled RSS 2.0 endpoint in src/pages/rss.xml.ts — about sixty lines, no dependency. @astrojs/rss is one import away and would do the same job; the consistent stance across this theme is that a serializer for six fields is content, not infrastructure. The same reasoning produces robots.txt and llms.txt as endpoints.
Two details in it are the kind that go wrong silently:
& is escaped first. Otherwise the replacement’s own ampersands get re-escaped — the classic bug in every hand-written escaper, and the reason it is one function rather than five inline .replace() chains.
Dates are RFC-822, not ISO-8601. RSS 2.0 requires it, and toUTCString() is the closest thing in the standard library. Emitting an ISO string here produces a feed that many readers silently refuse to date.
The permalink is also the guid (isPermaLink="true"), which is what stops a retitled post from reappearing as new in every subscriber’s reader.
The feed is linked from three places, per the theme’s SEO rules: BaseHead, llms.txt, and the footer’s Company column. Note that its href carries no trailing slash — it is a file, not a directory route, so trailingSlash: "always" does not apply.
Adding a post
- Create
src/data/blog/<slug>/index.md. - Give it
title,description,authors(an array of existing author slugs),pubDate,heroImageand at least onecategoriesentry. All six are required. - Put the hero under
src/assets/images/and reference it relatively. - Write the body below the frontmatter.
.mdxworks if you need components.
A new category needs nothing registered — its route, its chip and its count all derive from the frontmatter. Set draft: true to keep a post out of the index, the categories, the feed and the related rails all at once.