Skip to content

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

Terminal window
npm install @yamblog/core @yamblog/astro @yamblog/remark

Shared 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.

src/lib/blog.ts
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 sitemap

1. Listing page

Using the pre-built component

src/pages/blog/index.astro
---
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

src/pages/blog/[slug].astro
---
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}`}>&larr; {adjacent.prev.title}</a>}
{adjacent.next && <a href={`/blog/${adjacent.next.slug}`}>{adjacent.next.title} &rarr;</a>}
<section>
<h2>Related posts</h2>
{related.map(r => (
<a href={`/blog/${r.slug}`}>{r.title}</a>
))}
</section>

3. Category pages

src/pages/blog/category/[category].astro
---
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

src/pages/blog/tag/[tag].astro
---
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>

Server-side (Astro SSR or query param)

src/pages/blog/index.astro
---
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:

src/pages/search-index.json.ts
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.


---
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

src/pages/feed.xml.ts
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

src/pages/sitemap.xml.ts
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" # required
date: "2026-01-15" # required
author: "Your Name" # default: Anonymous
tags: ["astro", "blog"] # default: []
excerpt: "Short description" # optional, used in listings + RSS
category: "tutorials" # optional, used in category pages
coverImage: "/images/cover.jpg" # optional, used in featured/OG
featured: true # default: false
draft: 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.