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.

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 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.
{
"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:
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:
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
- The widgets plugin
Nineteen block types for post bodies.
- Kill the form spam
Cloudflare Turnstile on contact forms.
- The demo post
Every widget in one article.