Billet CMS

You write the schema.
They write the words.

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.

Get it on GitHub See how it works

Made for the person who writes the schema, and the person who fills it in.

For you

Content is code

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.

For your clients

An editor that keeps its shape

Each block gets one focused form, with its limits and image guidance built in. Drafts stay drafts until someone presses Publish.

For your stack

Nothing new in the way

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.

Describe a block once. The CMS handles the rest.

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.

1

Define

Blocks, slots and page types are described in cms.config.ts using a small field vocabulary built on Zod 4.

2

Publish the shape

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.

3

Edit and render

Editors work in drafts and publish when they're ready. Your routes read the published content with getSlot and getPage.

Six field types cover most of what a site needs.

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.

cms.config.tstext()
heading: text({
  max: 80,
  label: "Heading",
}),
Heading 25 / 80
Design that ships on time

Plain text with a character limit. The counter updates as your client types, and the limit is checked again when the draft is saved.

Fixed slots or free pages. Most sites use both.

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.

Slots

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.

cms.config.ts + routeslot()
export const homeHero = slot("home.hero", hero, {
  label: "Home hero",
  previewPath: "/",
});

// in your home route
const content = await getSlot(sql, "home.hero", {
  block: hero,
});

Page types

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.

cms.config.ts + routepageType()
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);

Editable words for anything your server sends.

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

Halden Studionow
Your password reset link
Choose a new password

Emails

Subject lines and body copy for transactional emails, edited by the people who write them.

!
Your card was declinedTry another card, or get in touch with your bank and we'll keep your basket for 24 hours.

Error messages

The wording people see when something goes wrong, written by whoever answers the support tickets.

Certificate of completion
invoice terms · footers · certificates

PDFs and documents

Terms, footers and certificate wording for the documents your server generates.

No projects yet Projects you start will show up here. Start a project

In-app copy

Empty states and onboarding hints inside your app, so wording tweaks stop turning into tickets.

emails/password-reset.tsyour server
const copy = await getSlot(sql, "email.reset", { block: resetEmail });

await mailer.send({
  to: user.email,
  subject: copy.subject,
  html: renderRichText(copy.body),
});

The details editors notice.

Drafts and preview

Every change is a draft until it's published, item by item. A preview link shows the draft on the real site.

History and restore

Each save is kept as a revision, so an earlier version is only ever a click away.

SEO and social cards

Pages carry their own description and social image, and the plugin's SEO helpers render the tags.

Sitemap ready

Published pages can be merged straight into your sitemap with one call.

Images, handled

Converted and optimised into your bucket, with size guidance and a focal point per image.

Your site's sign-in

No separate CMS accounts. Admins get in by default, and you decide which other roles can.

From zero to editing in four steps.

  1. 01Deploy the CMS on cms.yourdomain, pointed at your site's Postgres.
  2. 02Add the plugin to your site with bun add @alexpricedev/billet-cms.
  3. 03Declare your blocks, slots and page types in cms.config.ts, and publish them on deploy.
  4. 04Read content in your routes with getSlot and getPage.
Read the README on GitHub
cms.config.tsyour site
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: "/",
});
routes/home.tsyour site
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

A CMS your clients can use, for the Billet site you already have.

MIT licensed, and built on the same Bun and Postgres stack as your Billet site.

Get it on GitHub New to Billet? Start there