Blog

How the blog on this site works

A posts collection, one Astro route and a hub block. The whole thing took an afternoon, including one redirect bug worth a warning.

A vintage printing press feeding out a magazine page, a stack of paper beside it

This site has a blog now. Not a separate app: a posts collection, one Astro route, and a block that lists articles on the /blog page. Here is the anatomy of it - real code, not pseudocode - so you can build the same thing, or take it apart to see how EmDash pieces fit together.

The short version: entries in a posts collection become routable via a urlPattern, one dynamic route renders each slug, a hub block lists them on a page, and cache tags make publish invalidate exactly the pages that show the post. Everything below is the actual implementation on this site.

Collection
posts: title, excerpt, cover, body blocks
Route
src/pages/blog/[slug].astro
Hub page
/blog, a page with a site_posts block
Rendering
Server-rendered on every request
Extras
Drafts, revisions, per-post SEO, sitemap, RSS
The blog pipeline: posts collection, one route, the rendered article
The whole blog in three pieces: the posts collection holds the data, one route renders every slug, and the body blocks become the article.

The collection

The posts collection is regular EmDash schema. The interesting bits are the urlPattern, which makes entries routable and lands them in the sitemap, and the body field, a blocks field where all the widgets live.

seed.jsonjson
{
  "slug": "posts",
  "urlPattern": "/blog/{slug}",
  "supports": ["drafts", "revisions", "seo", "search"],
  "fields": [
    { "slug": "title", "type": "string", "required": true },
    { "slug": "excerpt", "type": "text" },
    { "slug": "cover", "type": "image" },
    { "slug": "body", "type": "blocks" },
    { "slug": "featured", "type": "boolean" }
  ]
}

Two fields carry the listing pages: excerpt is the card text and meta description fallback, cover is the card image and og:image. Skip them and every surface downstream looks unfinished.

The route, annotated

One file renders every post. This is its real core - the redirects, the lookup, the cache tag, and the SEO resolution:

src/pages/blog/[slug].astrotypescript
const slug = Astro.params.slug;
if (!slug) return Astro.redirect(`/blog/${Astro.url.search}`, 301);
// canonical form is slash-terminated; anything else 301s there
if (!Astro.url.pathname.endsWith("/"))
  return Astro.redirect(`${Astro.url.pathname}/${Astro.url.search}`, 301);

const [result] = await Promise.all([
  getEmDashEntry("posts", slug), getSiteChrome(Astro),
]);
if (result.error) { Astro.cache.set(false); return unavailableResponse(); }
const post = result.entry;
if (Astro.cache.enabled) Astro.cache.set(result.cacheHint);
if (!post) return Astro.rewrite("/404");

Four things worth stealing. The early 301s normalize the URL before any data work happens. The entry read runs in parallel with the site chrome read - one round trip, not two. A failed query sets no cache and returns a deliberately uncached 503 rather than a poisoned 500. And a missing post rewrites to the 404 page instead of rendering an empty shell.

The reading time on the post header is also computed, not stored: the route walks every block's text fields - prose, steps, checklists, quotes, facts - and divides the word count by 200. Every article's minutes stay honest automatically.

What the head gets

getSeoMeta resolves the SEO tab on each post - meta title, description, og image - and falls back to the title and excerpt when the fields are empty. Canonical and robots come along for free, and the cover doubles as og:image. Fill the SEO tab once and every surface agrees.

Why cacheHint matters

Passing the query's cacheHint to Astro.cache tags the response with the entries it read. Publishing a post then invalidates exactly the cached pages that show it: the article, /blog, and the homepage section. Skip the tag and edits wait out the cache lifetime. The same trick runs site-wide - the middleware tags public pages with site-scripts so changing analytics settings busts every page at once.

The hub page

/blog itself is an ordinary page with a site_posts block on it. The block queries the posts collection, orders by published date, and renders the newest article as a lead card with the rest in a grid. The page is content, not code: editors can move it, rename it and add blocks around it without touching the repo.

The same block with a limit of three is the "Latest from the blog" section on the homepage. One component, two placements, no duplicated markup.

The feed, annotated

rss.xml.ts builds one feed out of four collections. The whole trick is a small table - each collection declares which field holds its outbound link:

src/pages/rss.xml.tstypescript
const collections = [
  { slug: "posts",     linkField: "",            tag: "Post" },
  { slug: "tutorials", linkField: "url",         tag: "Tutorial" },
  { slug: "videos",    linkField: "youtube_url", tag: "Video" },
  { slug: "resources", linkField: "url",         tag: "Resource" },
];
// posts are own content: link = `/blog/${entry.id}/`
// hub entries: link = sanitizeHref(data[linkField])

Tutorials link out to bitdoze.com, videos to YouTube, resources to npm or the registry - and posts get the internal /blog/ path as the special case. Each collection read also passes its own cacheHint, so publishing anything refreshes the feed too.

Drafts and publishing

Saving a post writes a draft revision; the live page only changes when you hit publish. That is why a saved-but-unpublished edit can look lost on the site while being perfectly visible in the admin - and why the REST API answers a PUT with a new revision id rather than a changed page. The SEO tab sits on the same editor screen, so meta title and description are part of the publish checklist rather than an afterthought.


Two traps worth knowing

Ship list

If you are building your own

  • A posts collection with the fields above
  • urlPattern set to /blog/{slug}
  • A route that redirects non-canonical URLs, then renders body blocks
  • cacheHint passed to Astro.cache so publish busts the right pages
  • A hub page with a site_posts block
  • A Blog link in the primary menu
  • Posts folded into your RSS source list
  • One real published post, the rest follows
  • Excerpt, cover and SEO fields filled per post

Read next