Skip to content
AstroCraft Docs
On this theme

Blog & RSS

The blog is five routes over two collections, plus a feed. /blog/ draws the featured post and page one, /blog/page/<n>/ continues it, /blog/<slug>/ renders a post, /blog/category/<slug>/ and /blog/author/<slug>/ filter it, and /rss.xml serialises the lot. Thirteen sample posts, five authors and five categories ship, producing 25 pages.

The index, and why page one has no number

/blog/ promotes the newest post into a featured card and hands the rest to the grid. /blog/page/2/ continues from there, and /blog/page/1/ deliberately does not exist:

const href = (n: number) => (n <= 1 ? "/blog/" : `/blog/page/${n}/`);

Two URLs serving one page of content is a duplicate that a canonical tag then has to apologise for. It is also why the theme builds its own pager rather than using Astro’s paginate() helper: that helper is tied to a rest-param route (/blog/[...page]), which would collide with the post route /blog/[slug] sitting at the same depth.

The featured post is excluded from the grid, and that rule is written once:

export function griddedPosts(posts: readonly Post[]): Post[] {
  return posts.slice(1);
}

It used to be written three ways across two route files — a destructure on the index, a slice(1) on page two, and a .length - 1 inside that route’s getStaticPaths — so changing which post is promoted meant finding all three, and missing the third would emit paths for posts the grid no longer held.

POSTS_PER_PAGE is 9. With thirteen posts, that is one featured plus twelve gridded, which is two pages. Page two takes no featured prop: the promoted post is the one the reader scrolled past on page one, and re-promoting it reads as a duplicate rather than as a feature.

paginatePosts returns everything the pager needs in one object — the page’s posts, current, last, total, the 1-based from/to for “Showing 1–9 of 12”, the full pages array for numbered links, and previousHref/nextHref. The arithmetic underneath is pageRange in @js/postFacts, which is pure, shared with the getStaticPaths that decides how many pages to emit, and carries a runnable check including the “an empty archive still has page one” edge.

A post page

src/pages/blog/[slug].astro is thirty-nine lines and calls render() itself, because that call returns two things the page needs in different places: the <Content /> component for the body and the headings array for the reading rail. Passing the rendered pair down keeps Blog/Post.astro free of astro:content calls entirely.

The page draws seven bands: a header with the byline and meta, the cover, the body against a table-of-contents rail, the author bio card, a related-posts row, the site-level closing CTA and the footer.

Three of its values are derived rather than stored.

Reading timereadingTime(post.body) in @js/postFacts, plain arithmetic over the raw markdown.

The monograminitials(author.name), the two letters the design draws in place of a portrait wherever the author has no avatar.

Related postsorderRelated takes every post, the current one, an identity function and a grouping function, and returns same-category posts first, then the rest, with the current post removed. It is generic on purpose: the customer stories’ related row and the integrations directory’s use the same function with different group accessors.

The reading rail is ui/toc-rail, driven by the headings array and a named scroll timeline, so the progress indicator tracks the article with no JavaScript at all. The rendered markdown gets its typography from ui/_prose.css — the one stylesheet in the theme that styles bare tags, because it is the one place where the tags are not yours to class.

SEO on a post page

This is the first article-type page on the site, and it is where three parts of the SEO layer get their first caller:

<BaseLayout
  title={post.data.title}
  description={post.data.description}
  image={post.data.heroImage}
  schema={[article, crumbs]}
  article={{ published, modified, author: author.data.name }}
>

The article prop flips og:type to article and emits article:published_time and article:modified_time. getArticleSchema and getBreadcrumbSchema build the JSON-LD nodes, which BaseHead merges into the site graph. dateModified is only sent when the post actually declares an updatedDate — a stale dateModified hurts more than none — and the BreadcrumbList is paired with a visible breadcrumb, so the markup and the schema agree.

heroImage being required in the schema is what lets image be passed unconditionally, which is what gives every post a real social card with real og:image:width and og:image:height.

Category and author archives

Both are one route file each, both invented rather than transcribed, and both are documented as such in their own headers.

The category archive exists because the design draws the filter chips as a control with no target. Making them links to real routes gives them somewhere to go, and buys crawlable, linkable, JavaScript-free filtering. Slugs come from slugify(category), and the archive’s heading and description are {category} templates filled from blogData.

The author archive exists because the design draws its link: the author bio card ends in “More from Iris →”, and authorLink in the authors collection is where that points. Without the route, every post page would close on a 404. It emits paths only for authors who have actually published — an empty archive is a page with nothing on it.

Neither has a pager, and the reason is stated rather than assumed: the largest category holds four posts and the most prolific author three, both well inside POSTS_PER_PAGE. If one outgrows it, the archive band already takes a page prop, so it is a route change and not a component one.

Both archives, and the header’s “By topic” mega-menu column, derive their taxonomy from the posts themselves through postCategories, so adding a category means writing it in one post’s frontmatter and nothing else.

The RSS feed

/rss.xml is hand-rolled RSS 2.0 in about forty lines — no @astrojs/rss. It is the same stance the theme takes against an SEO package and an animation library: a feed is string building over a collection this repo already reads, and the dependency would own the one part of it that can actually go wrong (escaping) while adding a version to track.

The escaping is the part worth reading:

function escapeXml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    // …
    .replace(/'/g, "&apos;");
}

& runs first or the escapes this introduces get escaped again. The apostrophe is included here, unlike in the contact form’s HTML escaper: post titles are full of them, and a feed reader that hits a raw ' inside a single-quoted attribute renders broken markup.

Each item carries a guid with isPermaLink="false" — stating it false rather than relying on the default, because true would be a second claim about the URL to keep true. The feed reads defaultLocale explicitly rather than assuming, so the day a second locale exists this file is copied under pages/<locale>/ rather than rewritten.

It is linked from three places: BaseHead (<link rel="alternate">), /llms.txt, and the footer’s Resources column — which is the one row on the site whose href is a file rather than a directory, and the reason getLocalizedRoute leaves it un-slashed.

Adding a post

Create src/data/blog/en/my-post/index.md, fill the frontmatter, put the cover beside it, and build. The index picks it up, the pagination recalculates, the archives regenerate, the feed and the sitemap include it, and the header’s “By topic” column updates if the category is new.

Two things to get right, because the build will stop you otherwise: heroImage is required, and description and standfirst are different strings doing different jobs. Content Collections has the full schema.

Write the body as markdown with h2 sections — the reading rail is built from the headings array, so a post with no h2s renders a rail with nothing in it.

NEXT STEPCustomer Stories