Your content types live in TypeScript, so they're reviewed and deployed like the rest of your code. Your clients get an editor that won't let them break the layout, and your site reads what they publish straight from its own Postgres.
Blocks, slots and page types live in your repo, typed and reviewed in pull requests like everything else. Nobody clicks a schema together in an admin panel.
Each block gets one focused form, with its limits and image guidance built in. Drafts stay drafts until someone presses Publish.
The CMS shares your site's Postgres and its sign-in. Your pages never wait on it, because there's no API or webhook sitting between them.
Each block is described once, in code. The CMS turns that description into a form, and your site renders whatever gets published, so there's nothing in between to keep in sync.
Blocks, slots and page types are described in cms.config.ts using a small field vocabulary built on Zod 4.
On deploy, bunx billet-cms-publish writes your definitions into cms_ tables. The CMS builds its forms from those rows and never imports your code.
Editors work in drafts and publish when they're ready. Your routes read the published content with getSlot and getPage.
Each field you declare becomes a control in the editor, with its rules attached. The tabs below show what your editors get for each one.
heading: text({
max: 80,
label: "Heading",
}),Plain text with a character limit. The counter updates as your client types, and the limit is checked again when the draft is saved.
body: richText({ label: "Body", }), // on your site import { renderRichText } from "@alexpricedev/billet-cms/server";
Formatted text, stored as structured JSON (not HTML) and rendered on your site with renderRichText.
photo: image({
label: "Photo",
minWidth: 1200,
aspect: "16:9",
}),Uploads are converted and optimised on the way in. Too-small images are refused, and a focal point keeps the subject in frame however the site crops it.
cta: link({
label: "Button link",
}),One field covers the whole button, from its words to where it goes. Internal pages are picked from a list, so nobody types a URL by hand.
images: list(
image({ label: "Image", minWidth: 400 }),
{ label: "Images", min: 1, max: 6 },
),Any field can repeat. Editors can add and reorder items within the minimum and maximum you set.
items: list(
group({
title: text({ max: 60 }),
body: text({ max: 200 }),
}),
{ label: "Steps", min: 1, max: 4 },
),A group bundles fields that belong together (a title and body per step, say), and a list repeats the whole set.
A slot works well where the layout is yours and only the words change. When editors need to build whole pages, a page type lets them stack the blocks you allow, within the limits you set.
One block in a spot you control, like a home hero or a footer call to action. Your template stays put; editors change what's in it.
export const homeHero = slot("home.hero", hero, { label: "Home hero", previewPath: "/", }); // in your home route const content = await getSlot(sql, "home.hero", { block: hero, });
Editors create pages at their own URLs and stack the blocks you allow. Limits keep the layout in check: one hero, and only at the top.
export const landing = pageType("landing", { label: "Landing", allowedBlocks: [hero, textBlock, gallery, quote], limits: { hero: { max: 1, position: "top" } }, }); // in your catch-all route const page = await getPage(sql, url.pathname);
A slot doesn't have to sit on a web page. Any code that can reach your database can read one, so the copy in your emails and generated documents can live in the CMS too (and change without a deploy).
Subject lines and body copy for transactional emails, edited by the people who write them.
The wording people see when something goes wrong, written by whoever answers the support tickets.
Terms, footers and certificate wording for the documents your server generates.
Empty states and onboarding hints inside your app, so wording tweaks stop turning into tickets.
const copy = await getSlot(sql, "email.reset", { block: resetEmail }); await mailer.send({ to: user.email, subject: copy.subject, html: renderRichText(copy.body), });
Every change is a draft until it's published, item by item. A preview link shows the draft on the real site.
Each save is kept as a revision, so an earlier version is only ever a click away.
Pages carry their own description and social image, and the plugin's SEO helpers render the tags.
Published pages can be merged straight into your sitemap with one call.
Converted and optimised into your bucket, with size guidance and a focal point per image.
No separate CMS accounts. Admins get in by default, and you decide which other roles can.
import { block, image, slot, text } from "@alexpricedev/billet-cms"; export const hero = block("hero", { label: "Hero" }, { heading: text({ max: 80, label: "Heading" }), photo: image({ label: "Photo", minWidth: 1200 }), }); export const homeHero = slot("home.hero", hero, { label: "Home hero", previewPath: "/", });
import { getSlot } from "@alexpricedev/billet-cms/server"; import { hero } from "../cms.config"; const content = await getSlot(sql, "home.hero", { block: hero }); // content.heading, content.photo: typed from your block
MIT licensed, and built on the same Bun and Postgres stack as your Billet site.