Content Management System
Meno's CMS lets you manage repeatable, structured content — blog posts, docs, products, team members — separately from the pages that render it. Content is stored as plain JSON files in your Astro project, the schema is defined by a single template page, and everything builds to static HTML with astro build.
A Meno CMS has three moving parts: the collection (a folder of item files), the template page (which defines the schema and renders one item), and the way you read fields in markup. This guide walks through all three.
Content collections
A content collection is a folder under src/content/<collection>/, where each file is one item stored as JSON. A posts collection looks like this:
src/content/posts/
introducing-meno.json
webflow-cms-limits-2026.json
webflow-cms-limits-2026.draft.jsonEach item file carries the field values you defined for the collection, plus two system fields Meno manages for you:
_id— a stable identifier, equal to the filename stem (introducing-meno.jsonhas_id: "introducing-meno")._createdAt— an ISO timestamp set when the item was first created.
A typical published item:
{
"_id": "introducing-meno",
"_createdAt": "2026-01-13T12:00:00.000Z",
"title": "Introducing Meno",
"slug": "introducing-meno",
"excerpt": "Meet Meno: the AI-powered visual web builder.",
"content": { "type": "doc", "content": [ ] }
}A rich-text field (content above) is stored as a structured TipTap document, not a string — that distinction matters when you render it (see Rendering item fields).
Drafts
An unpublished edit is a sibling file named <name>.draft.json. The published item is always the file without .draft:
introducing-meno.json— the published item that ships in your production build.introducing-meno.draft.json— an unpublished edit of the same item.
When you preview in the Meno editor, draft sidecars are merged over their published siblings so you see your latest edits. A production astro build ships the published files only and ignores the drafts.
The template page is the schema
A collection's schema does not live in a config file — it lives in the collection's template page at src/pages/<collection>/[slug].astro. This is an ordinary Astro dynamic route: it has a getStaticPaths() and renders one item per route. What makes it a CMS template is that its meta.source is "cms" and it carries the schema in meta.cms.
Here is the real template page for this site's docs collection, src/pages/docs/[slug].astro (frontmatter only):
---
import { getCollection } from 'astro:content';
import { getCollectionList, i18n, queryList, style } from 'meno-astro';
import { BaseLayout, Embed, Link } from 'meno-astro/components';
import Heading from '../../components/ui/Heading.astro';
import RichText from '../../components/ui/RichText.astro';
export async function getStaticPaths() {
const entries = await getCollection("docs");
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: "docs",
name: "Documentation",
slugField: "slug",
urlPattern: "/docs/{{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" },
category: {
type: "reference",
label: "Category",
required: true,
collection: "docs-categories"
},
order: { type: "number", label: "Sort Order" }
}
}
};
---The meta.cms schema
The meta.cms object is the single source of truth for the collection. Its keys:
id— the collection identifier. It names the folder (src/content/docs/) and is the string you pass togetCollectionandgetCollectionList.name— the display name shown in the editor.slugField— the field whose value becomes each item's URL slug (here,slug).urlPattern— the route template for a single item, e.g./docs/{{slug}}.fields— a map of field name to field definition (type,label,required,default, and type-specific options).
The urlPattern also determines where the template file lives on disk. The static prefix of the pattern becomes the route directory: /blog/{{slug}} emits to src/pages/blog/[slug].astro, and /docs/{{slug}} to src/pages/docs/[slug].astro. The route param is always slug; slugField only tells getStaticPaths which field to read for the param value.
To change a collection — add a field, rename it, change its type, adjust the URL — you edit meta.cms, and nothing else. In the Meno editor a template is addressed as /templates/<collection> (for example /templates/docs), even though its file on disk is src/pages/docs/[slug].astro.
Derived boilerplate — don't hand-edit it
Three lines in a template page are derived boilerplate, regenerated from meta.cms every time the page is saved:
the
import { getCollection } from 'astro:content';import,the
getStaticPaths()function,the
const { cms } = Astro.props;line.
The codec recognizes and skips these on parse — they carry no model state, exactly the way it skips a component's resolveProps destructuring. Treat them as read-only output: change meta.cms, and they regenerate. If you edit them by hand they will be overwritten on the next save.
src/content.config.ts
Astro requires a content config to resolve collections during a build. Meno generates src/content.config.ts for you, registering each collection with a permissive schema so astro build can load the items:
import { defineCollection, z } from 'astro:content';
import { menoCmsLoader, resolveCmsEntrySlug } from 'meno-astro';
const docs = defineCollection({
loader: menoCmsLoader({ base: './src/content/docs' }),
schema: z.record(z.string(), z.any())
.transform((data) => resolveCmsEntrySlug(data, 'docs')),
});
export const collections = { docs };This file is generated plumbing, not the schema. The menoCmsLoader loads published items (and merges draft sidecars in dev); the permissive z.record schema exists only so the build can resolve the collection. The real, typed schema is the template page's meta.cms — that is what the editor reads and what you edit.
Rendering item fields
Inside a template page, the current item is available as cms, and you reference its fields with {{cms.field}} templates in the model, which emit as {i18n(cms.field)} in markup.
The i18n() wrap matters: getStaticPaths passes the raw item data, so a field that holds an internationalized value ({ _i18n: true, en: "…", pl: "…" }) would render as [object Object] if you interpolated it bare. The i18n() resolver returns the right per-locale string, and it is identity for plain (non-i18n) values, so wrapping is always safe. This is why a plain field renders as {i18n(cms.title)}, never {cms.title}:
<Heading text={i18n(cms.title)} size="1" tag="1" cms={cms} />
<Text text={i18n(cms.publishedAt)} size="small" cms={cms} />See Internationalization for how i18n field values work end to end.
Rich-text fields
A rich-text field is the exception: its value is a structured object (a TipTap document), not a string. Rendering it with {i18n(cms.content)} would print [object Object]. Instead, render it as HTML through richTextWithComponents:
<Fragment set:html={richTextWithComponents(cms.content, cmsComponents)} />richTextWithComponents (from meno-astro) resolves the per-locale value, converts the TipTap document to HTML, and renders any components embedded in the rich text — TipTap menoComponent nodes such as an embedded Button or a Vimeo embed. It renders them against cmsComponents, the generated registry at src/cmsComponents.ts (an import.meta.glob over src/components/). The registry and its import are generated plumbing — don't hand-edit src/cmsComponents.ts.
On this site the rich-text body is wrapped in a small RichText component, src/components/ui/RichText.astro, whose body is exactly that one <Fragment> plus styles for the rendered HTML:
---
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" } }, __props, { root: true })}
cmsrt={true}>
<Fragment set:html={richTextWithComponents(cms.content, cmsComponents)} />
</div>Listing items elsewhere
The template page renders one item per route. To show many items on another page — a blog index, a docs sidebar, a related-posts strip — read the collection in frontmatter with getCollectionList(...) and map over it in the body:
const docsList = await getCollectionList("docs", {}, Astro, getCollection);{docsList.map((doc, docIndex) => (
<Link href={i18n(doc._url)}>{i18n(doc.title)}</Link>
))}Loop-variable fields are CMS-data bindings too, so they wrap in i18n() the same way cms does. For filtering, sorting, limiting, and the queryList helper, see Lists.
Field types and references
Each entry in meta.cms.fields has a type — string, text, rich-text, number, boolean, date, select, file, and reference. A reference field links to another collection (the docs example references docs-categories via collection: "docs-categories"), and resolved references are read with dot notation, e.g. {i18n(cms.category.name)}. For the full list of field types and their options, see CMS fields. For internationalized field types, see Internationalization.
Ready to build something concrete? Follow Build a blog for a posts collection end to end, or Build a product catalog for a catalog with references and filtering.