Astro Guide
Building a Complete Blog with Yamblog + Astro
This guide covers every feature of the yamblog ecosystem in an Astro 5 project: listing, post detail, category pages, tag pages, search, related posts, RSS, sitemap, and JSON-LD.
Install
npm install @yamblog/core @yamblog/astro @yamblog/remarkShared blog instance
Create a single instance and import it everywhere. Posts are cached after the first filesystem read, so all pages share one I/O pass.
import { defineBlog } from '@yamblog/core';
export const blog = defineBlog('src/content/posts', import.meta.env.SITE);That’s it. defineBlog(contentDir, siteUrl) resolves the content dir relative to cwd,
and the site URL flows through to blog.generateRss() and blog.generateSitemap()
automatically — no need to repeat it at every call site.
Zero-config: defineBlog() with no arguments defaults to src/content/posts and
auto-detects the site URL from SITE, PUBLIC_SITE_URL, NEXT_PUBLIC_BASE_URL, or VERCEL_URL.
Full config: pass an options object for schema, sorting, related posts, etc.:
import { defineBlog } from '@yamblog/core';import { z } from 'zod';
export const blog = defineBlog({ contentDir: 'src/content/posts', siteUrl: import.meta.env.SITE, schema: z.object({ title: z.string(), date: z.coerce.date(), /* ... */ }),});File layout
src/ content/ posts/ hello-world.md lib/ blog.ts shared blog instance pages/ blog/ index.astro listing [slug].astro post detail category/ [category].astro category pages tag/ [tag].astro tag pages feed.xml.ts RSS sitemap.xml.ts sitemap1. Listing page
Using the pre-built component
---import BlogListPage from '@yamblog/astro/components/BlogListPage.astro';import { blog } from '../lib/blog';
const query = Astro.url.searchParams.get('q') ?? undefined;const posts = query ? await blog.search(query) : await blog.getPosts();---
<BlogListPage posts={posts} query={query} title="Blog" />Building your own listing
---import { blog } from '../../lib/blog';
const posts = await blog.getPosts();---
<main> {posts.map(post => ( <article> <a href={`/blog/${post.slug}`}> <h2>{post.title}</h2> </a> <p class="meta"> {post.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })} · {post.readingTime} min read · {post.author} </p> {post.excerpt && <p>{post.excerpt}</p>} <div> {post.tags.map(tag => <a href={`/blog/tag/${tag}`}>{tag}</a>)} </div> </article> ))}</main>2. Post detail page
Using the pre-built component
---import BlogPostPage from '@yamblog/astro/components/BlogPostPage.astro';import { blog, SITE_URL } from '../../lib/blog';
export async function getStaticPaths() { const posts = await blog.getPosts(); return posts.map(post => ({ params: { slug: post.slug } }));}
const { slug } = Astro.params;const post = await blog.getPostBySlug(slug!);const adjacent = await blog.getAdjacentPosts(slug!);
const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, datePublished: post.date.toISOString(), author: { '@type': 'Person', name: post.author }, keywords: post.tags.join(', '), url: `${SITE_URL}/blog/${post.slug}`,};---
<BlogPostPage post={post} adjacent={adjacent} jsonLd={jsonLd} />Building your own post layout
Use toHtml from @yamblog/remark for full pipeline control — compose any
@yamblog/remark plugins and rehype transforms:
---import { blog, SITE_URL } from '../../lib/blog';import { toHtml, remarkToc, remarkEmbed } from '@yamblog/remark';
export async function getStaticPaths() { const posts = await blog.getPosts(); return posts.map(post => ({ params: { slug: post.slug } }));}
const { slug } = Astro.params;const post = await blog.getPostBySlug(slug!);const adjacent = await blog.getAdjacentPosts(slug!);const related = await blog.getRelatedPosts(slug!);
const html = await toHtml(post.content, { remarkPlugins: [remarkToc, remarkEmbed],});
const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, datePublished: post.date.toISOString(), author: { '@type': 'Person', name: post.author }, url: `${SITE_URL}/blog/${post.slug}`,};---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
<article class="prose" set:html={html} />
{adjacent.prev && <a href={`/blog/${adjacent.prev.slug}`}>← {adjacent.prev.title}</a>}{adjacent.next && <a href={`/blog/${adjacent.next.slug}`}>{adjacent.next.title} →</a>}
<section> <h2>Related posts</h2> {related.map(r => ( <a href={`/blog/${r.slug}`}>{r.title}</a> ))}</section>3. Category pages
---import { blog } from '../../../lib/blog';
export async function getStaticPaths() { const categories = await blog.getCategories(); return categories.map(category => ({ params: { category } }));}
const { category } = Astro.params;const posts = await blog.getPostsByCategory(category!);---
<h1>Category: {category}</h1><ul> {posts.map(post => ( <li> <a href={`/blog/${post.slug}`}>{post.title}</a> </li> ))}</ul>4. Tag pages
---import { blog } from '../../../lib/blog';
export async function getStaticPaths() { const tags = await blog.getTags(); return tags.map(tag => ({ params: { tag } }));}
const { tag } = Astro.params;const posts = await blog.getPostsByTag(tag!);---
<h1>Tag: {tag}</h1><ul> {posts.map(post => ( <li> <a href={`/blog/${post.slug}`}>{post.title}</a> </li> ))}</ul>5. Search
Server-side (Astro SSR or query param)
---import { blog } from '../../lib/blog';
const query = Astro.url.searchParams.get('q') ?? undefined;const posts = query ? await blog.search(query) : await blog.getPosts();---
<form method="GET"> <input name="q" value={query} placeholder="Search posts..." /> <button type="submit">Search</button></form>
{posts.map(post => ( <a href={`/blog/${post.slug}`}>{post.title}</a>))}Client-side search index
Expose a JSON endpoint and search in the browser:
import type { APIRoute } from 'astro';import { blog } from '../lib/blog';
export const GET: APIRoute = async () => { const index = await blog.generateSearchIndex(); return new Response(JSON.stringify(index), { headers: { 'Content-Type': 'application/json' }, });};Then fetch /search-index.json on the client and filter by title, excerpt, tags.
6. Featured posts
---import { blog } from '../lib/blog';
const featured = await blog.getFeaturedPosts();---
<section> <h2>Featured</h2> {featured.map(post => ( <article> <a href={`/blog/${post.slug}`}>{post.title}</a> {post.coverImage && <img src={post.coverImage} alt={post.title} />} </article> ))}</section>Mark posts as featured in frontmatter:
---title: "My Best Post"featured: true---7. RSS feed
import type { APIRoute } from 'astro';import { blog } from '../lib/blog';
export const GET: APIRoute = async () => { const xml = await blog.generateRss({ title: 'My Blog', description: 'Latest posts from my blog', author: 'Your Name', });
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 's-maxage=3600, stale-while-revalidate', }, });};Add a <link> tag in your layout so browsers discover it:
<link rel="alternate" type="application/rss+xml" title="My Blog" href="/feed.xml" />8. Sitemap
import type { APIRoute } from 'astro';import { blog } from '../lib/blog';
export const GET: APIRoute = async () => { const xml = await blog.generateSitemap();
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'Cache-Control': 's-maxage=3600, stale-while-revalidate', }, });};9. JSON-LD (structured data)
There is no helper for this — the mapping from a Post to a JSON-LD object is
straightforward to write once in your own layout:
---const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, datePublished: post.date.toISOString(), dateModified: post.date.toISOString(), author: { '@type': 'Person', name: post.author }, keywords: post.tags.join(', '), url: `${SITE_URL}/blog/${post.slug}`, ...(post.coverImage && { image: post.coverImage }),};---
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />Pass it to BlogPostPage via the jsonLd prop, or inject it yourself in your own layout.
10. Custom markdown pipeline
toHtml from @yamblog/remark runs the unified pipeline and accepts any remark/rehype plugins:
---import { toHtml, remarkToc, remarkEmbed, remarkInteractive } from '@yamblog/remark';import remarkDirective from 'remark-directive';
const html = await toHtml(post.content, { remarkPlugins: [ [remarkToc, { heading: 'Contents', maxDepth: 4 }], remarkEmbed, remarkDirective, remarkInteractive, ],});---
<article class="prose" set:html={html} />If you only need raw markdown (e.g., to pass to a different renderer), post.content is
the untransformed string — toHtml is opt-in.
Frontmatter reference
---title: "Post Title" # requireddate: "2026-01-15" # requiredauthor: "Your Name" # default: Anonymoustags: ["astro", "blog"] # default: []excerpt: "Short description" # optional, used in listings + RSScategory: "tutorials" # optional, used in category pagescoverImage: "/images/cover.jpg" # optional, used in featured/OGfeatured: true # default: falsedraft: true # default: false — drafts are excluded from getPosts()---Custom fields
Extend the default schema to add typed custom frontmatter fields. Add astro check to your build script to enforce types in .astro templates:
"build": "astro check && astro build"See the Custom Fields recipe for validation types and full examples.