@bettercms-ai/astro
v0.14.4
Published
The BetterCMS adapter for Astro — a `bettercms()` integration, a `bettercms:client` virtual module, typed content loaders, draft preview, and native .astro rendering components.
Readme
@bettercms-ai/astro
The BetterCMS adapter for Astro — a bettercms() integration, a
bettercms:client virtual module, typed content loaders, draft preview, and
native .astro rendering components. The Astro equivalent of @sanity/astro.
Install
npm install @bettercms-ai/astroSetup
// astro.config.mjs
import { defineConfig } from "astro/config";
import bettercms from "@bettercms-ai/astro";
export default defineConfig({
integrations: [
bettercms({
apiUrl: "https://api.bettercms.ai", // or PUBLIC_BCMS_API_URL
workspace: "my-workspace", // or PUBLIC_BCMS_WORKSPACE
// projectId, studioUrl, mediaUrl optional
}),
],
output: "server", // required for draft mode + live reads
});Environment variables
Every public value resolves in this order: bettercms({ ... }) option, then env, then the
bcms-content.json snapshot the deploy workflow writes before the build.
| Variable | Option | Read | Default / notes |
| --- | --- | --- | --- |
| PUBLIC_BCMS_API_URL | apiUrl | config time | Required (option, env or snapshot) |
| PUBLIC_BCMS_WORKSPACE | workspace | config time | Required (option, env or snapshot) |
| PUBLIC_BCMS_PROJECT_ID | projectId | config time | Optional project scope for forms |
| PUBLIC_BCMS_MEDIA_URL | mediaUrl | config time | https://cdn.bettercms.ai |
| PUBLIC_BCMS_STUDIO_URL | studioUrl | config time | Unset. <BcmsVisualEditing> falls back to https://bettercms.ai |
| BCMS_API_KEY | none | server runtime | content:read, or content:read:draft for draft previews. Never bundled |
| BCMS_DRAFT_SECRET | none | server runtime | Signs the draft cookie. Runtime process.env wins over a build-time value. Without it /api/bcms/draft/enable returns 500 |
The BetterCMS-generated deploy workflow sets PUBLIC_BCMS_API_URL, PUBLIC_BCMS_WORKSPACE
and PUBLIC_BCMS_PROJECT_ID for the build. Its repo secret BCMS_API_KEY reaches only the
content-fetch step, never the build. On your own host, set the two server variables yourself.
This package ships no revalidation route. Point the project's revalidation webhook
(Project → Settings) at your rebuild hook. Its secret (the dashboard calls it
BETTERCMS_WEBHOOK_SECRET) signs the body as x-bettercms-signature: sha256=<hmac>.
Add the virtual-module + Astro.locals types to src/env.d.ts:
/// <reference types="@bettercms-ai/astro/env" />Reading content
---
import { loadPage, loadForms } from "bettercms:client";
import BcmsBlocks from "@bettercms-ai/astro/components/BcmsBlocks.astro";
const page = await loadPage(Astro, "home");
const { items: forms, turnstileSiteKey } = await loadForms(Astro);
---
{page && <BcmsBlocks blocks={page.blocks} forms={forms} turnstileSiteKey={turnstileSiteKey} />}Loaders take Astro so they automatically read drafts when draft mode is on:
loadPage, loadEntry, loadEntries, loadForms. The raw client and
getClient(Astro) are exported too.
SEO — per-page <head>
loadPage() returns the page's metaTitle, metaDescription, and the rich metaJson
(OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Resolve them
page-over-site with resolveSeo and map the result into your <head> (same precedence
as the live *.bettercms.site renderer):
---
import { loadPage } from "bettercms:client";
import { resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/astro";
const { slug } = Astro.params;
const page = await loadPage(Astro, slug ?? "home");
const siteDefaults: SiteSeoDefaults = {
metaDescription: "Selected work, experience, and contact information.",
ogImage: "https://example.com/og-default.png",
twitterHandle: "@acme",
};
const seo = page ? resolveSeo(page, siteDefaults) : null;
---
{seo && (
<Fragment slot="head">
<title>{seo.title}</title>
{seo.description && <meta name="description" content={seo.description} />}
{seo.canonical && <link rel="canonical" href={seo.canonical} />}
{seo.og.title && <meta property="og:title" content={seo.og.title} />}
{seo.og.description && <meta property="og:description" content={seo.og.description} />}
{seo.og.image && <meta property="og:image" content={seo.og.image} />}
<meta property="og:type" content={seo.og.type} />
<meta name="twitter:card" content={seo.twitter.card} />
{seo.twitter.image && <meta name="twitter:image" content={seo.twitter.image} />}
{seo.twitter.site && <meta name="twitter:site" content={seo.twitter.site} />}
{seo.jsonLd.map((node) => (
<script type="application/ld+json" set:html={JSON.stringify(node)} />
))}
</Fragment>
)}Reads are cached; a SEO edit appears on the next fetch. For instant refresh on publish, configure the project's revalidation webhook (Project → Settings) against your rebuild or on-demand-revalidation hook.
Components
| Component | Purpose |
| --- | --- |
| @bettercms-ai/astro/components/BcmsBlocks.astro | Render a page's blockJson — every block type the builder can author (heading, text/richtext, image, button, spacer, video, columns, form, section, navbar, footer, slider, tabs, component), with block style tokens applied. |
| @bettercms-ai/astro/components/BcmsForm.astro | Render + submit a form (conditional fields, honeypot, Turnstile). |
| @bettercms-ai/astro/components/BcmsImage.astro | Optimized image with a 1x/2x srcset via the media transform endpoint. |
| @bettercms-ai/astro/components/BcmsLiveBlocks.astro | BcmsBlocks as a hydrated island that lets the Visual Editor re-render your DRAFTS with your own components and CSS. See below. |
Markup is class-driven and unstyled — you own the CSS.
Phone fields with a country code picker
A phone field whose author enabled Country code picker in the builder renders a country
control beside the number. Whatever the visitor picks, the field still submits one value,
under its own key, in E.164 (+14155550123) — there is no second key to handle.
Two classes are yours to style:
| Class | Element |
|---|---|
| bcms-phone | the wrapper around the country control and the number |
| bcms-phone-country | the country control itself |
The country control is a native <select> built at build/SSR time, so no phone library
reaches the visitor's browser. The trade is that the number is not reformatted as it is
typed — the value is still composed correctly on submit. @bettercms-ai/next makes the other
choice and ships a formatter in a lazy chunk.
Live editing on a static site — <BcmsLiveBlocks>
When the Visual Editor frames a static build it can only show what was built, so editing a
draft falls back to the platform's approximate renderer and the canvas stops looking like your
site. <BcmsLiveBlocks> fixes that for pages whose body is a block tree: it renders the same
markup <BcmsBlocks> does, but as a client:load island that accepts draft documents from the
editor over postMessage (same-origin, parent frame only, and only after the editor's own
handshake — a third-party page that iframes your site is on a foreign origin and gets nothing).
Nothing is fetched by the page and no credentials enter it.
It needs a JSX renderer, which a component cannot add for you:
npm i @astrojs/preact preact// astro.config.mjs
import preact from "@astrojs/preact";
export default defineConfig({ integrations: [bettercms(), preact({ compat: true })] });---
import BcmsLiveBlocks from "@bettercms-ai/astro/components/BcmsLiveBlocks.astro";
const page = await getPage(Astro.params.slug);
const { forms } = await readForms();
---
<BcmsLiveBlocks slug={page.slug} blocks={page.blocks} forms={forms} />compat: true is required — the island reuses the React <BcmsDraftBridge> from
@bettercms-ai/next so both SDKs speak one protocol. Roughly 10 kB of Preact plus the block
renderer; @astrojs/react works too if your project already uses it.
⚠️ It emits the React class names, not this package's. <BcmsBlocks> from
@bettercms-ai/next classes its output
bcms-block bcms-block--text, bcms-button--primary, bcms-column; BcmsBlocks.astro above
classes the same tree bcms-text, bcms-btn-primary, bcms-cols. They are NOT interchangeable: a
stylesheet written for the .astro grammar styles nothing on this island, so swapping a page over
unstyles it. Cover both spellings — the starters wrap them in one :is() selector for exactly this
reason — or take the class list from @bettercms-ai/next's src/blocks.tsx.
It only helps pages you render through BcmsBlocks. A hand-written .astro layout, or
content pulled into your own components, is invisible to the bridge — for those, structural
draft preview needs a server-rendered draft route instead (output: 'server' + the draft-mode
routes below).
Draft mode
The integration injects /api/bcms/draft/enable?token=<jwt>&redirect=/path and
/api/bcms/draft/disable. Generate the preview-token link from the dashboard;
visiting enable validates the token against the backend, sets a signed cookie,
and subsequent loads return draft content. Disable with the disable route.
Two draft lanes
| Lane | Build | How the canvas shows a draft |
| --- | --- | --- |
| SSR | output: "server" + the draft routes above + BCMS_DRAFT_SECRET + a content:read:draft BCMS_API_KEY | The loaders read the draft on every request, so hand-written layouts preview too |
| Static | Any static build, with the page body rendered through <BcmsLiveBlocks> | The editor pushes the draft block tree to the island over postMessage. No fetch, no key in the page. Only the BcmsLiveBlocks region updates |
Headless projects (your own host)
BetterCMS never builds, releases or deploys a headless project. Any build, artifact or deploy
request for one returns 409 HEADLESS_NO_BUILD.
- Deploy: push to your repository. Your provider (Vercel, Netlify or Cloudflare Pages) builds it. For fresh content on publish, point the revalidation webhook at the provider's rebuild hook.
- Draft: use your framework's draft route on the provider's preview deployment URL.
For Astro that is the SSR lane above:
output: "server", the injected/api/bcms/draft/*routes,BCMS_DRAFT_SECRET, and aBCMS_API_KEYcarryingcontent:read:draft. Keep the draft key off production.
