Build a Blog with Meno
This tutorial builds a complete blog end to end: a posts content collection with typed fields, a template that generates one page per post, an index page that lists every post, real content, and a published static site. The blog you see on this very site (meno-web) is built exactly this way, so every snippet here is grounded in real project files.
A Meno blog is a normal Astro project. Posts live as Astro content collection items under src/content/posts/, the per-post page is a dynamic route at src/pages/blog/[slug].astro, and the listing is a plain page at src/pages/blog.astro. You can build all of it visually in the Meno editor or by hand-editing the .astro files — they stay in sync through the meno-astro codec.
By the end you will have:
A
postscollection with title, slug, excerpt, rich-text content, publish date, and a featured imageA post template that renders each item at
/blog/<slug>A blog index at
/blogthat lists posts newest-first, each card linking to its postSeveral real posts (including unpublished drafts)
A built, publishable static site
Step 1 — Plan the collection
Start by deciding the shape of a post. A blog post needs a headline, a URL-friendly slug, a short summary for the listing, the full body, a publish date, and a hero image. That maps to this field set:
Field | Type | Purpose |
|---|---|---|
|
| The post headline |
|
| URL segment — |
|
| Short summary shown on the index |
|
| The full post body |
|
| Hero image |
|
| Publish date, used for sorting |
You can add more fields later (an author string, a featured boolean to flag posts, a category select) — the schema is just a literal you edit. For the full list of field types and their options, see CMS fields. For how collections work as a whole, see CMS.
In meno-astro a collection is defined by its template page, not by a separate config file. The template both renders a single post and carries the schema in its meta.cms. That is what you create next.
Step 2 — Create the collection template
Create the dynamic route src/pages/blog/[slug].astro. The directory (blog/) comes from the static prefix of meta.cms.urlPattern, and the [slug].astro filename makes it an Astro dynamic route. Set meta.source to "cms" and put the schema in meta.cms:
---
import { getCollection } from 'astro:content';
import { i18n, style } from 'meno-astro';
import { BaseLayout } from 'meno-astro/components';
import Heading from '../../components/ui/Heading.astro';
import RichText from '../../components/ui/RichText.astro';
import Section from '../../components/ui/Section.astro';
import Text from '../../components/ui/Text.astro';
export async function getStaticPaths() {
const entries = await getCollection("posts");
return entries.map((entry) => ({
params: { slug: entry.data.slug ?? entry.id },
props: { cms: entry.data },
}));
}
const { cms } = Astro.props;
const meta = {
title: "{{cms.title}}",
description: "{{cms.excerpt}}",
source: "cms",
cms: {
id: "posts",
name: "Blog Posts",
slugField: "slug",
urlPattern: "/blog/{{slug}}",
fields: {
title: { type: "string", label: "Title", required: true },
slug: { type: "string", label: "URL Slug", required: true },
excerpt: { type: "text", label: "Excerpt" },
content: { type: "rich-text", label: "Content" },
featuredImage: { type: "image", label: "Featured Image" },
publishedAt: { type: "date", label: "Publish Date" }
}
}
};
---
<BaseLayout meta={meta}>
<!-- body added in Step 3 -->
</BaseLayout>The const meta literal is the source of truth: id names the collection (and its src/content/posts/ folder), name is its label in the editor, slugField picks the field used for the route param, urlPattern (/blog/{{slug}}) maps each item to its URL, and fields is the schema.
The import { getCollection }, the getStaticPaths() function, and const { cms } = Astro.props; are derived boilerplate — Meno regenerates them from meta.cms on every save, and the codec ignores them on read. You never hand-tune them; you edit meta.cms. To add or change a field, edit the fields literal.
In the Meno editor this template is addressed as /templates/posts. Astro's content layer also needs a matching collection entry in src/content.config.ts; Meno keeps that in sync with a permissive loader so astro build can resolve posts — the template's meta.cms remains the real schema.
Step 3 — Build the post template body
The template renders the *current* item through cms.<field> bindings. Plain fields are wrapped in i18n(...) (it resolves localized values and is a no-op for plain strings). The rich-text body is the one exception: a rich-text value is a structured object, so you render it as HTML with richTextWithComponents, never as a text interpolation.
This project keeps the rich text in a small RichText.astro component that receives cms and renders the body:
---
import { resolveProps, richTextWithComponents, style } from 'meno-astro';
import { cmsComponents } from '../../cmsComponents';
const __props = resolveProps(Astro, {});
const { class: className } = __props;
const { cms } = Astro.props;
---
<div class={style({ base: { lineHeight: "1.7", fontSize: "18px" } }, __props, { root: true })} cmsrt={true}><Fragment set:html={richTextWithComponents(cms.content, cmsComponents)} /></div>richTextWithComponents(cms.content, cmsComponents) converts the stored TipTap document to HTML and renders any components embedded in the rich text against the generated src/cmsComponents.ts registry. The import { cmsComponents } line is derived boilerplate — don't hand-edit the registry.
With that component in place, the template body renders the title, the date, and the body:
<BaseLayout meta={meta}>
<Section theme="primary" cms={cms}>
<article class={style({ base: { maxWidth: "720px", margin: "0 auto" } })}>
<header class={style({ base: { marginBottom: "32px" } })}>
<Heading text={i18n(cms.title)} size="1" tag="1" cms={cms} />
<Text text={i18n(cms.publishedAt)} size="small" cms={cms} />
</header>
<RichText cms={cms} />
</article>
</Section>
</BaseLayout>Note text={i18n(cms.title)} and text={i18n(cms.publishedAt)} — bare cms.<field> chains wrapped in i18n() — versus the rich-text body, which goes through <Fragment set:html={...} />. If you wrote text={i18n(cms.content)} instead, the page would print [object Object], because the content field is an object, not a string. Pass cms={cms} to child components so they can resolve their own bindings.
Step 4 — Add posts
Each post is one JSON file under src/content/posts/. The filename stem is the item id. Meno writes these for you when you add an item in the editor's CMS panel, but the shape is plain and hand-editable. A real post from this project:
{
"_id": "introducing-meno",
"_createdAt": "2026-01-13T12:00:00.000Z",
"_updatedAt": "2026-01-15T11:04:12.178Z",
"title": "Introducing Meno",
"slug": "introducing-meno",
"excerpt": "Meet Meno: the AI-powered visual web builder that turns your ideas into production-ready websites in minutes.",
"content": {
"type": "doc",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "We built Meno because creating websites shouldn't be this hard." }] },
{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "What is Meno?" }] }
]
},
"featuredImage": "/images/intro.webp",
"publishedAt": "2026-01-13"
}A few things to know about the item files:
The
_id,_createdAt, and_updatedAtkeys are managed by Meno; the rest are your schema fields.The
contentfield is a TipTap document object (the rich-text format) — that is why it renders throughrichTextWithComponents, not as a string.The
slugfield drives the URL: this item is served at/blog/introducing-meno.
Add a few more posts the same way — each is its own JSON file with a unique slug.
Drafts. An unpublished edit lives as a sibling .draft.json file next to the published one, for example introducing-meno.draft.json beside introducing-meno.json. In development (the Meno editor and astro dev) the draft is merged over its published sibling so you can preview unpublished changes; a production astro build ships only the published .json files. A brand-new post that has never been published exists only as a .draft.json until you publish it.
Step 5 — Build the blog index
The listing is a normal page at src/pages/blog.astro. Query the collection once in the frontmatter with getCollectionList, then map over the result in the body. Each card is a BlogCard component linking to the post's URL:
---
import { getCollection } from 'astro:content';
import { getCollectionList, i18n } from 'meno-astro';
import { BaseLayout } from 'meno-astro/components';
import BlogCard from '../components/ui/BlogCard.astro';
import Heading from '../components/ui/Heading.astro';
import Section from '../components/ui/Section.astro';
import Spacer from '../components/ui/Spacer.astro';
const meta = { title: "Blog", description: "Latest articles and updates" };
const postsList = await getCollectionList("posts", { sort: { field: "publishedAt", order: "desc" } }, Astro, getCollection);
---
<BaseLayout meta={meta}>
<Section theme="primary">
<Heading text="Blog" size="1" tag="1" />
<Spacer size="48" />
{postsList.map((post, postIndex) => (
<BlogCard href={`/blog/${i18n(post.slug)}`} image={i18n(post.featuredImage)} title={i18n(post.title)} date={i18n(post.publishedAt)} excerpt={i18n(post.excerpt)} />
))}
</Section>
</BaseLayout>A few details:
getCollectionList("posts", { sort: { field: "publishedAt", order: "desc" } }, Astro, getCollection)resolves the collection at build time, newest first. You can also passfilter,limit, andoffsetin that options object.The mapped values are raw item data, so each field access is wrapped in
i18n(...)—i18n(post.title),i18n(post.excerpt), and so on — to resolve localized fields (and pass plain values through unchanged).The href uses a template literal so the slug is interpolated: `
href={/blog/${i18n(post.slug)}}resolves to/blog/introducing-meno`, which matches the route from Step 2.The loop variable is
postand its index ispostIndex— the index name is always<var>Index.
BlogCard is a normal component that takes href, image, title, date, and excerpt props and renders the card markup and styles. For more on collection-backed lists — sorting, filtering, limits, and prop-based lists — see Lists.
Step 6 — Preview and publish
Preview the blog in the Meno editor: open /blog to see the index, and open any post to see the template render with that item's data. Because drafts merge over published items in development, you preview unpublished edits exactly as they will read once live.
When the content is ready, publish. Publishing promotes each .draft.json to its published .json and produces the static site — the index, one page per post at /blog/<slug>, and the assets — ready to deploy. See Publishing for the full flow.
That is the whole pattern: a template page that owns the schema and renders one item, content items in src/content/posts/, and an index page that queries the collection and maps it to cards. The same approach scales to any structured content — see Build a Product Catalog to apply it to products with prices, filtering, and sorting.