Skip to content
AstroCraft Docs
On this theme

Blog & RSS

The blog is two routes over one collection, four sections and one helper module. /blog/ lists posts newest first with the most recent one featured; /blog/<id>/ renders the post with a sticky table of contents, an author bio and a related grid. Seven sample posts across six authors ship with the theme.

The index

src/pages/blog/index.astro is a thin shell. It calls withAuthors(await getPublishedPosts()) — drafts filtered, newest first, each post paired with its resolved first author — and hands the array to PostIndex, which does three things with it.

The newest post becomes the featured card. PostIndex destructures const [featured, ...grid] = items, so “featured” is a position, not a frontmatter flag. Publish something newer and the previous feature demotes itself into the grid. The featured slot renders FeaturedPostCard, the wide image-left card; the rest render BlogCard.

The filter pills derive from the posts. Categories are collected with [...new Set(items.flatMap(({ post }) => post.data.categories ?? []))], so adding a new categories value to any post’s frontmatter grows the filter bar by itself. There is nothing to register. Filtering is one small bundled script; with JavaScript off, every card is simply visible and the pills are inert.

Everything rises on scroll. The featured card and the grid both sit inside StaggerReveal, the house card-reveal recipe. See Motion & Animation for how that degrades.

The design’s “Load more articles” pill was deliberately dropped. It could never render under seven posts, and its pagination state was the script’s only real complexity. If you grow past the point where one page is reasonable, the ui/pagination primitive is already built — see the Components Reference.

The post page

src/pages/blog/[id].astro is where most of the assembly happens, and it is worth reading once because it is the fullest example of a thin route in the theme. getStaticPaths maps published posts to params, and the route body then:

  • renders the entry (render(post)), resolves the first author, and fetches the published list — all three concurrently through Promise.all
  • computes reading time as Math.max(1, Math.round(words / 200)) over the raw markdown body, which is close enough for a “min read” label and costs nothing
  • picks the related posts
  • builds the BlogPosting JSON-LD and the breadcrumb trail
  • passes heroImage as the page’s social image, so og:image:width and og:image:height are the real dimensions

It then composes PostHeroPostBodyRelatedPostsCta, with Navbar carrying onDark={false} because this page’s hero is light rather than the dark panel the home page uses.

The table of contents

render(post) returns headings, which the route passes to PostBody. The TOC filters to depth === 2, so only ## headings appear. Astro’s markdown pipeline gives every heading an id, so the links are plain same-page anchors with no client work to generate them.

Keeping the current section highlighted is one rAF-coalesced scroll pass through the shared onScrollFrame helper — not an IntersectionObserver, and not a per-component scroll listener. The link whose section is on screen gets aria-current, which is what colours it.

The TOC is hidden lg:flex and sticky at top-28. On narrow screens it is simply absent rather than collapsed into a disclosure.

Prose styling

Article body styles live in an is:global block inside PostBody.astro, scoped under .post-prose. There is no @tailwindcss/typography dependency; the rules are written against the theme’s own tokens, so the article follows light and dark mode like everything else.

One deliberate exception: the code block is a fixed-dark panel — base-950 with a violet border in both themes, the same treatment the CTA panel uses — which means Shiki’s own background is overridden. If you want code blocks to flip with the theme, that override is the one place to change.

The author bio

The card at the foot of the article reads name, role, about and avatar from the resolved author entry. Its social chips are a knowing shortcut: the authors schema carries no per-author socials, so the card reuses the first three entries from navData.social. If your authors need their own accounts, add the field to the authors schema and read it here — the file says as much in a ponytail: comment.

Three cards, chosen by a two-pass sort in the route:

const category = categories?.[0];
const others = published.filter((p) => p.id !== post.id);
const sameCategory = (p) => Boolean(category && p.data.categories?.includes(category));
const related = [...others.filter(sameCategory), ...others.filter((p) => !sameCategory(p))].slice(0, 3);

Posts sharing the current post’s first category come first, then everything else, and the list is already newest-first because getPublishedPosts sorted it. Slice to three. A post with no categories still gets three related cards — the newest three — rather than an empty section, and RelatedPosts renders nothing at all when there are no other posts.

The RSS feed

/rss.xml is a hand-built RSS 2.0 document. There is no @astrojs/rss dependency, for the same reason there is no SEO package: a feed is four tags in a loop.

The split is worth copying if you write another endpoint. src/js/rss.ts holds buildRssFeed and escapeXml and imports nothing from astro:*, which is what lets rss.selfcheck.ts run it under plain Node. src/pages/rss.xml.ts decides only what goes in the feed:

items: posts.map((post) => ({
  title: post.data.title,
  description: post.data.description,
  url: new URL(`blog/${post.id}/`, base).href,
  pubDate: post.data.pubDate,
}));

Three details in the builder are deliberate. Every interpolated value is escaped, URLs included — an unescaped & in a query string is the ordinary way a hand-built feed becomes malformed XML. Item URLs carry the trailing slash so they match the canonical tag rather than pointing at a redirect, which is how a reader ends up with two entries for one post. And escapeXml escapes ' where the contact form’s escapeHtml deliberately does not — this is XML parsed by machines, not an email read by a human.

The feed is linked from three places: <link rel="alternate"> in BaseHead, the llms.txt endpoint, and the footer’s legal row via navData.legal.

Adding a post

Create src/data/blog/<slug>.md, give it a hero image under src/assets/images/blog/, and fill in the frontmatter:

---
title: "Your post title"
description: "One sentence — it is the card blurb, the meta description and the RSS description."
authors: ["sarah-jenkins"]
pubDate: "2026-07-26"
heroImage: ../../assets/images/blog/your-image.jpg
categories: ["Engineering"]
---

Your opening paragraph, then `##` headings for anything you want in the table of contents.

Everything else follows: the index picks it up, the filter pills grow if the category is new, the feed includes it, getStaticPaths builds its page, and the JSON-LD is generated from the same frontmatter. Set draft: true while you work and it is invisible everywhere until you remove the flag.

NEXT STEPIntegrations Directory