@cmsweb-dev/cms
v1.6.0
Published
Show posts from the cmsweb.dev CMS on your website: the API types, the rule checker, React and Astro renderers that draw each post with your own components, a Markdown copy of each post, the sitemap, llms.txt, hreflang and refresh helpers, and the command
Maintainers
Readme
@cmsweb-dev/cms
Show posts from the cmsweb.dev CMS on your website, drawn with your own React or Astro components.
Posts live in the CMS. Your site reads them from its API and draws them with this package. The CMS checks every post against your site's rules when it is saved, so a post can never use a component your site does not have. No MDX is compiled on your site and nothing from a post ever runs as code.
What is inside
| Import | What it gives you |
| --- | --- |
| @cmsweb-dev/cms | createCms, the client your site reads the CMS with. The types of everything it sends (Post, PostSummary, Redirect...), the Rules type, the API's routes, treeToMarkdown, the sitemap, llms.txt, hreflang and refresh helpers |
| @cmsweb-dev/cms/render | renderTree: draws a post with your React components |
| @cmsweb-dev/cms/astro | Tree: draws a post in Astro with your components, .astro or React |
| @cmsweb-dev/cms/parse | mdxToTree, readExports and checkRules: the same checks the CMS runs on every save |
| @cmsweb-dev/cms/guide | This guide as text: this README and the CMS's install prompt are both made from it |
1. Install
npm install --save-exact @cmsweb-dev/cmsPin the exact version, so your site moves to a new one only when you choose. React 19 is needed only for @cmsweb-dev/cms/render, and Astro 7 only for @cmsweb-dev/cms/astro.
2. Settings
Put the site's key in your site's .env.local, never in a committed file, and name it with a comment in .env.example:
CMS_KEY= # the site's key, from its page in the CMS
CMS_AGENT_KEY= # only for cms-import, on this machine: the agent key from the CMS's home pageThe key names your site, so it is the only setting your site reads. CMS_URL is only for reading another CMS than https://cmsweb.dev, such as in tests. Make the client once:
// lib/cms.ts
import { createCms } from "@cmsweb-dev/cms";
// Import this file from server code only: the key sends your rules and reads drafts.
export const cms = createCms(process.env.CMS_KEY!, { url: process.env.CMS_URL });- The key sends your rules and reads drafts, so it must stay on your server: never import this file from a component that runs in the browser. In Next.js,
import "server-only";at its top makes that an error. - Set
CMS_KEYon your host too, for production and for preview deploys: every build and every page reads posts with it, and a build without it fails. CMS_AGENT_KEY, from the CMS's home page, is only for moving your posts in (step 12). It can write every site's posts, so it stays in.env.localon your machine, never on your host.
3. Send your rules
Your rules list each component a post may use, the props it takes, and what goes between its tags. Write them next to your components, with a strict object for each component's props so a prop it does not know is refused. Then send them all at once, from one script such as cms:rules in your package.json:
// scripts/cms-rules.ts
import type { Rules } from "@cmsweb-dev/cms";
import { z } from "zod";
import { cms } from "../lib/cms";
const rules: Rules = {
Callout: {
props: z.toJSONSchema(z.strictObject({ tone: z.enum(["info", "warning"]) })),
children: "markdown",
},
YouTube: {
props: z.toJSONSchema(z.strictObject({ id: z.string(), title: z.string() })),
},
};
console.log("The CMS now holds", await cms.sendRules(rules));What a component takes between its tags:
| children | Means |
| --- | --- |
| "none" | Nothing, the default: <YouTube id="..." title="..." /> |
| "text" | One line of plain text, which your component gets as a string (in Astro, in its slot) |
| "markdown" | Any Markdown, drawn with the same components as the rest of the post |
A wrong key throws a CmsError with status 401. Broken rules throw one that lists every problem. Send the rules again whenever a component's props change.
A prop holds plain values only: text, numbers, true, false, null, lists and objects. A component that took JSX in a prop, such as a link inside a table cell or an icon beside a value, takes an object instead and draws it itself: a cell { text, link, href }, with link the words of text that become the link, or { text, icon }. Give each field of such an object a .describe() in its rule, so whoever writes a post knows what goes in it.
4. Read posts
Read every post through the client, never from files:
// lib/posts.ts
import { cms } from "./cms";
export const posts = await cms.posts({ type: "blog", locale: "en" });
export const post = await cms.post({ type: "blog", locale: "en", slug: "my-first-post" });
export const redirects = await cms.redirects();| Call | Request | Gives |
| --- | --- | --- |
| cms.posts({ type?, locale? }) | GET https://cmsweb.dev/v1/sites/your-site/posts | Every published post, without its body, newest first |
| cms.post({ type, locale, slug }) | GET https://cmsweb.dev/v1/sites/your-site/posts/:type/:locale/:slug | One post with its tree, or undefined when there is none |
| cms.redirects() | GET https://cmsweb.dev/v1/sites/your-site/redirects | The 301s your site should serve, each { from, to } |
| cms.sendRules(rules) | PUT https://cmsweb.dev/v1/sites/your-site/rules | The components the CMS now holds (step 3) |
Post types are blog, guide and tool. Every post comes with its path on your site, such as /blog/my-first-post: use it for links and for your sitemap. A request the CMS refuses throws a CmsError with its status and the reason. A read that cannot reach the CMS at all is tried 3 times, then throws the network error, so one dropped connection does not fail your build.
Ask the CMS which posts exist every time you need to know, never a list of file names or one written at build time: a post published since the build must count, in every locale.
5. Draw a post
// components/article.tsx
import type { Post } from "@cmsweb-dev/cms";
import { renderTree, type Components } from "@cmsweb-dev/cms/render";
import { Callout, SiteLink, YouTube } from "./mdx";
const components: Components = {
Callout,
YouTube,
// Optional: your own look for any Markdown element.
a: SiteLink,
};
export function Article({ post }: { post: Post }) {
return (
<article>
<h1>{post.title}</h1>
{renderTree(post.tree, components)}
</article>
);
}- Pass every component in your rules. A post that uses one you did not pass throws an error, so a missing piece never goes unnoticed.
- The Markdown elements are
p,h1,h2,h3,h4,h5,h6,blockquote,ul,ol,li,pre,code,em,strong,a,img,hrandbr. Any you do not pass is drawn as the plain HTML tag. h1,h2andh3come with anidmade from their text, for your table of contents and#links: "Créer un sprite ?" iscreer-un-sprite, and a repeated heading gets-1,-2.headingId(text)makes the same id, for a component that draws its own heading. An override that wants no id leaves it out. A post saved before version 1.6.0 has them once it is saved again.renderTree(post.tree, components, { unwrapImages: true })draws a paragraph that holds only an image as the image alone, for animgthat draws a block such as a<figure>, which HTML does not allow inside a<p>.renderTreeuses no hooks and no context, so it works in React Server Components and withreact-dom/server. In Astro, draw withTreeinstead (step 10), so.astrocomponents draw and islands hydrate.- Never compile or run MDX on your site: the tree was checked when the post was saved.
6. A post's page
The page reads the post by its type, locale and slug, and builds its head from the post's fields:
| In the head | From |
| --- | --- |
| <title> and the meta description | title, description |
| The canonical URL | Your main URL, then path |
| og:image | image, with imageAlt |
| hreflang | hreflang(post, await cms.posts({ type }), siteUrl, { codes, xDefault }) |
| Article JSON-LD | datePublished from date, dateModified from dateModified (or date), author |
translationKeypairs a post with its translations: the posts of its type with the same key.hreflanggives the head's links, its own included, and{}when the post has no translation.codesturns a locale into its hreflang code, such as{ fr: "fr-CA" }, andxDefaultnames the locale x-default points to.translationsOf(post, posts)gives the post's path in each locale, for your language switcher.- Link to a post by its
path. Never put a locale in front of a slug yourself: the site's settings in the CMS decide whether the first locale has a prefix, andpathalready holds it. - A slug the CMS does not have may be a post that was renamed or deleted:
redirectFor(await cms.redirects(), path)gives where it went, withpaththe URL the page answers on. Answer it with a 301, and only then with your 404. - A post published after your build must render on its first visit, then stay cached until the CMS refreshes it. Your framework's recipe (step 10) says how.
7. Images
post.imageand every image in a post's body are full URLs onmedia.cmsweb.dev. Print them as they are: never put your site's URL in front of them, in a page or in a sitemap.- Allow that host in your image optimizer (step 10), so post images are drawn like your own.
- Moving your posts in (step 12) uploads their images and keeps
cms-import-images.json: each old path with its new URL. Serve a 301 from each old image URL to its new one, so image search follows.
8. Sitemap and llms.txt
// lib/listings.ts
import { llmsLines, sitemapEntries, sitemapXml } from "@cmsweb-dev/cms";
import { cms } from "./cms";
const SITE_URL = "https://www.example.com";
// One sitemap per locale: your own pages (here the locale's home page, on a
// site where every locale has its prefix), then every post in that locale.
export async function sitemap(locale: string): Promise<string> {
const own = [{ url: SITE_URL + "/" + locale }];
return sitemapXml([...own, ...sitemapEntries(await cms.posts(), SITE_URL, { locale })]);
}
export async function llmsTxt(): Promise<string> {
return ["# Example", "", ...llmsLines(await cms.posts(), SITE_URL), ""].join("\n");
}sitemapEntriesgives each post its full URL, itsdateModified(ordate) aslastmod, and its image.{ locale }makes a sitemap per locale, and{ hreflang }lists each post's translations in it too. Its entries are what Next'sMetadataRoute.Sitemaptakes, andsitemapXmlwrites them as XML, your own pages' entries with them.llmsLinesgives one line ofllms.txtper post: a link to it and what it is about.treeToMarkdown(post.tree, { components })gives a post as Markdown, for a Markdown copy of each page and anllms-full.txt. A hook per component writes it,(props, children) => string | null, withchildrenthe Markdown between its tags, andnullleaves the component out. A component without a hook gives its children's Markdown, so one that holds nothing (an image, a diagram) is left out.- Both are built from every post, so a refresh must be able to build them again: when a post's entry changes, the refresh clears each path in its
sitemapoption,/sitemap.xmland/llms.txtunless you name your own (step 9). A sitemap written as a file at build time never shows a new post.
9. Cache and refresh your pages
The API never caches its answers: cache the pages your site builds from them. After a write that changes what your site shows (a publish, a save of a published post, a delete, a restore, a new slug), the CMS sends a POST to /api/cms/refresh on your site's main URL. Answer it with cms.handleRefresh(request, clear, { sitemap }), in the route your framework's recipe (step 10) shows:
clear(paths)is yours: it clears each page from your cache.pathsholds the post's path (its old one too, after a new slug), the page that lists it and, when the post's entry in your sitemap andllms.txtchanged, every path insitemap.- It answers
401unless the CMS signed the call with your key in the last 5 minutes, and500whenclearthrows, so the CMS shows that the refresh failed. - The main URL is https on the domain set on your site's page in the CMS, and that host must answer the
POSTitself: a redirect (the bare domain to www, http to https) turns thePOSTinto aGET, and the refresh fails. Set the domain to the host your site serves, such aswww.example.com. - A locale middleware must let
/api/cms/refreshthrough: its matcher skips/api. - The CMS sends each refresh once and never waits for your site. Build no page again on a timer for it: a refresh your site missed (it was down) is sent again by Refresh the site's pages in the post's menu in the CMS, or by the
refreshtool of its MCP. - A content page must never read a login cookie on the server, or it can no longer be cached.
10. Your framework
Your site needs a server for its post pages: they render on demand, and the refresh clears them. A static site can stay static everywhere else and render only its post pages on demand. A site with no server at all, such as a single-page app, cannot show a post published after its build.
Next.js
| Part | How |
| --- | --- |
| The refresh route | app/api/cms/refresh/route.ts |
| Clear a page | revalidatePath(path) |
| A post published after the build | The post page lists the posts in generateStaticParams and keeps dynamicParams = true: each page is built once, again only on a refresh, and a new slug renders on its first visit. Under app/[locale], every copy of the page keeps it too |
| Draw a post | renderTree in a server component (step 5) |
| The image host | images: { remotePatterns: [{ protocol: "https", hostname: "media.cmsweb.dev" }] } in next.config |
| Sitemap and llms.txt | A route handler with dynamic = "force-static" at a path that does not end in sitemap.xml, such as /sitemaps/en, which next.config rewrites /sitemap.xml to. Next ships app/sitemap.ts as a file a refresh cannot rebuild, and on Vercel a prerendered path ending in /sitemap.xml fails the build. /llms.txt can be a force-static route handler as it is |
// app/api/cms/refresh/route.ts
import { revalidatePath } from "next/cache";
import { cms } from "@/lib/cms";
// Built from every post: cleared when a post's entry in them changed.
const LISTINGS = ["/sitemaps/en", "/sitemaps/fr", "/llms.txt"];
export function POST(request: Request) {
return cms.handleRefresh(request, (paths) => paths.forEach((path) => revalidatePath(path)), { sitemap: LISTINGS });
}// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound, permanentRedirect } from "next/navigation";
import { hreflang, redirectFor } from "@cmsweb-dev/cms";
import { Article } from "@/components/article";
import { cms } from "@/lib/cms";
const SITE_URL = "https://www.example.com";
type Props = { params: Promise<{ locale: string; slug: string }> };
// Each post is built once, then again only when the CMS refreshes it, and a
// slug published after the build renders on its first visit.
export const dynamicParams = true;
export async function generateStaticParams() {
return (await cms.posts({ type: "blog" })).map(({ locale, slug }) => ({ locale, slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const post = await cms.post({ type: "blog", locale, slug });
if (!post) return {};
return {
title: post.title,
description: post.description,
alternates: { canonical: SITE_URL + post.path, languages: hreflang(post, await cms.posts({ type: "blog" }), SITE_URL) },
openGraph: { images: post.image ? [{ url: post.image, alt: post.imageAlt }] : [] },
};
}
export default async function Page({ params }: Props) {
const { locale, slug } = await params;
const post = await cms.post({ type: "blog", locale, slug });
if (!post) {
// The path this page answers on, as the site writes its URLs: here every
// locale has its prefix. A renamed or deleted post moved from it.
const movedTo = redirectFor(await cms.redirects(), "/" + locale + "/blog/" + slug);
if (movedTo) permanentRedirect(movedTo);
notFound();
}
return <Article post={post} />;
}// app/sitemaps/[locale]/route.ts
import { sitemap } from "@/lib/listings";
// Built once, then again only when the CMS refreshes its path. next.config
// rewrites /sitemap.xml and /fr/sitemap.xml here.
export const dynamic = "force-static";
export function generateStaticParams() {
return [{ locale: "en" }, { locale: "fr" }];
}
export async function GET(_request: Request, { params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return new Response(await sitemap(locale), { headers: { "content-type": "application/xml" } });
}Astro
| Part | How |
| --- | --- |
| The refresh route | src/pages/api/cms/refresh.ts, with export const prerender = false |
| Clear a page | context.cache.invalidate({ path }): Astro's route cache (Astro 7), with a provider in astro.config: memoryCache() on Node, or your adapter's cacheVercel(), cacheNetlify() or cacheCloudflare() |
| A post published after the build | Post pages render on demand (export const prerender = false) and stay cached with Astro.cache.set({ maxAge: 31536000 }) until a refresh clears them. A slug redirectFor finds answers Astro.redirect(to, 301). The rest of the site can stay prerendered |
| Draw a post | <Tree tree={post.tree} components={components} /> from @cmsweb-dev/cms/astro, with .astro components or React ones. A component that hydrates goes in as an .astro wrapper that sets its client:* directive, since Astro hydrates only what an .astro file imports. unwrapImages and the heading ids work as in step 5 |
| The image host | image: { domains: ["media.cmsweb.dev"] } in astro.config. On Cloudflare, also the adapter's imageService: "cloudflare": a Worker has no sharp, so without it images are served as they are, never resized |
| Sitemap and llms.txt | src/pages/sitemap.xml.ts, on demand, answering sitemapXml(...), and src/pages/llms.txt.ts the same way |
- On Cloudflare,
@astrojs/cloudflare14 builds a Worker with static assets, not a Pages site, and needsastro^7.4.0 andwrangler^4.125.0. A site on Cloudflare Pages moves to a Worker.cacheCloudflare()from@astrojs/cloudflare/cachetags each page with its path, socontext.cache.invalidate({ path })purges it at the edge. - A component gets what is between its tags in its slot, never as a string: a React component gets it as the
childrenit draws. Props keep the names the tree gives them, such asclassName, so an override written forrenderTreeworks as it is. - Astro keeps a post's fields in a YAML frontmatter, at
src/content/{type}/{locale}/{slug}.mdx:cms-importreads it asmetadata(step 12).
// src/pages/api/cms/refresh.ts
import type { APIRoute } from "astro";
import { cms } from "@/lib/cms";
export const prerender = false;
export const POST: APIRoute = (context) =>
cms.handleRefresh(context.request, (paths) => Promise.all(paths.map((path) => context.cache.invalidate({ path }))));---
// src/pages/blog/[slug].astro
import { redirectFor } from "@cmsweb-dev/cms";
import Tree from "@cmsweb-dev/cms/astro";
import Callout from "@/components/Callout.astro";
import FaqIsland from "@/components/FaqIsland.astro";
import { cms } from "@/lib/cms";
export const prerender = false;
const slug = Astro.params.slug!;
const post = await cms.post({ type: "blog", locale: "en", slug });
if (!post) {
const movedTo = redirectFor(await cms.redirects(), "/blog/" + slug);
return movedTo ? Astro.redirect(movedTo, 301) : new Response(null, { status: 404 });
}
Astro.cache.set({ maxAge: 31536000 });
---
<article>
<h1>{post.title}</h1>
<Tree tree={post.tree} components={{ Callout, Faq: FaqIsland }} unwrapImages />
</article>---
// src/components/FaqIsland.astro
import Faq from "./Faq";
---
<Faq client:visible {...Astro.props} />Node (Hono, Express)
| Part | How |
| --- | --- |
| The refresh route | POST /api/cms/refresh. Hono passes c.req.raw. Express and node:http pass await nodeRequest(req), with no body parser before the route |
| Clear a page | Delete the page from your own cache, or purge it at your CDN |
| A post published after the build | Render each page on request, and keep its HTML until a refresh clears it |
| Draw a post | renderTree, turned into HTML with renderToString from react-dom/server |
| The image host | Nothing to set, unless your Content-Security-Policy lists image hosts: add https://media.cmsweb.dev to img-src |
| Sitemap and llms.txt | A route that answers sitemapXml(...), and one for /llms.txt |
// server.ts
import { createServer } from "node:http";
import { nodeRequest } from "@cmsweb-dev/cms";
import { cms } from "./lib/cms";
// Your page cache: each page's HTML by its path, kept until a refresh.
const pages = new Map<string, string>();
createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/api/cms/refresh") {
const answer = await cms.handleRefresh(await nodeRequest(req), (paths) => paths.forEach((path) => pages.delete(path)));
res.writeHead(answer.status, { "content-type": "application/json" }).end(await answer.text());
return;
}
// Your pages: from the cache, or rendered on request and kept there.
}).listen(3000);11. Preview drafts
Nothing to add. Beside each post, the CMS shows your own page, copied as your site serves it and without its scripts, with the post's body in it as it is written. It works the same whatever your site is built with.
- Your components show as labelled boxes while the post is being written. Once a published post is saved, your site refreshes it and the copy is your own page, exact.
- A post not published yet borrows the page of a published post of its type.
- The CMS copies the page from your site's server, so it previews on the URLs the internet reaches: your main URL, or a preview deploy among the site's other URLs. It reaches
http://localhost:3000only when the CMS runs on that same machine. drafts: truestill reads drafts with your key, on the server only, should your site want a preview of its own. A draft comes withstatus: "draft"and today's date until it is published.
12. Move your posts in
If your posts are MDX files today, cms-import moves them in. It names the site from CMS_KEY and writes with CMS_AGENT_KEY, both from .env.local. Without --write it is a dry run: it reads every file, checks it against the rules the CMS holds, and lists what it would upload and write.
npx cms-import "content/{type}/{locale}/{slug}.mdx"
npx cms-import "content/{type}/{locale}/{slug}.mdx" --write- The pattern names where each part of a post's key sits. A folder that is not named after a type of post takes
--type, such asnpx cms-import "content/guides/{slug}.mdx" --type=guide. Without{locale}, the posts are in the site's first locale, or in--locale.--only=<slug>moves one post. - Each
export constis read as data withreadExports, never run.metadata'stitle,description,date,dateModified,author,tags,order,translationKey,imageandimageAltbecome the post's fields, andfaqandfeatureListits extra. Any other key is left out. - A YAML frontmatter, as Astro writes one, is read as
metadata, with a date kept as written. A file with both a frontmatter andexport const metadatais refused: keep the fields in one place. {/* */}comments are taken out of the body, each line kept as a blank line, so a refused line has the number it has in the file.- The dry run lists every prop that holds code, JSX in a table cell most often. Change the component and its rule to take an object (step 3), send the rules again, write the object in the file, and run the dry run again.
- Every image path in a post (
/content/a.jpg, in Markdown or in a component's props) whose file is underpublic/(or--public) is uploaded, keeping its file's name, and the post points at its new URL. - It is safe to run again: a post that exists is updated, and
cms-import-images.jsonkeeps each image sent. Each post is published, and its note in the CMS names the file it came from.
13. After the move
- Search the repo for everything that still reads the post files or their names: hreflang lists, a language switcher, a sitemap or
llms.txtbuilt from the folders, a prebuild step. Each must readcms.posts()instead. - Then delete the post files and their MDX pipeline (the loader, the imports, the generated lists), so no page can still read them.
- Write in the site's
CLAUDE.md(orAGENTS.md) and in its skills that posts live in the CMS and are written through its MCP, never as files. Otherwise the next agent edits a file, and nothing on the site changes.
Other helpers
| Export | Does |
| --- | --- |
| apiUrl(base, route, params, query?) | The full URL of an API route, every value encoded |
| postPath(site, post) | A post's path, from a site's locales and URL patterns |
| listPath(site, post) | The page that lists a post's type, such as /blog |
| redirectFor(redirects, path) | Where a renamed or deleted post's URL went, for your 301, or undefined |
| hreflang(post, posts, siteUrl, { codes?, xDefault? }) | A post's hreflang links, its own included, by translationKey: {} when it has no translation |
| translationsOf(post, posts) | A post's path in each locale it is published in, for a language switcher |
| sitemapEntries(posts, siteUrl, { locale?, hreflang? }) | A sitemap entry per post: its URL, lastmod and image, in the shape Next's MetadataRoute.Sitemap takes |
| sitemapXml(entries) | The sitemap as XML, your own pages' entries included |
| llmsLines(posts, siteUrl) | One llms.txt line per post: a link and what it is about |
| handleRefresh(request, key, clear, { sitemap? }) | The whole refresh route: cms.handleRefresh(request, clear, options?) is the same with your key |
| nodeRequest(req) | A Node or Express request as the Request the refresh reads |
| pathInSite(href, hosts) | The path a link points to on your site (any of its hosts), or undefined if it leaves it |
| isSitePath(path) | Whether a path stays on your site: //host and /\host do not |
| checkSitePaths(site) | Checks a site's locales and URL patterns, and lists what is wrong |
| linksIn(nodes) | Everything in a post's tree that can be a link: each Markdown link, and every text in a component's props |
| treeToMarkdown(tree, { components? }) | A post as Markdown, for a Markdown copy of a page and llms-full.txt: each component as its hook writes it (step 8) |
| headingId(text) | The id the CMS gives a heading, made from its text, for a component that draws a heading of its own |
| mdxToTree(source, rules) | Turns MDX into a tree, or lists every rule it breaks, with its line |
| mdxToTreeWithLines(source, rules) | mdxToTree, plus the lines each block at the top of the tree is written on, for an editor that shows a post beside its source |
| readExports(source) | Splits an MDX file into its export const values or its YAML frontmatter, read as data and never run, and its body |
| checkRules(value) | Checks that a value is valid rules, such as before you send them |
Versions
The major version follows the API: 1.x reads /v1. A breaking change comes as a new major with a new API next to this one, so your site moves only when you update it.
If renderTree or Tree throws "this post's tree is version 2", the CMS has moved ahead of your site: update this package.
