@bettercms-ai/next
The BetterCMS adapter for Next.js (App Router). Typed, cache-aware content reads,
draft preview, and llms.txt generation — the deterministic floor under your CMS.
Pairs with @bettercms-ai/codegen: generate BetterCMSSchema from your
content models, then get fully-typed getEntry/listEntries with autocomplete.
Install
npm install @bettercms-ai/next
# peers: next >= 14, react >= 18
Quick start
// lib/cms.ts
import { createBetterCMS } from "@bettercms-ai/next";
import type { BetterCMSSchema } from "./bettercms.generated"; // from @bettercms-ai/codegen
export const cms = createBetterCMS<BetterCMSSchema>({
workspace: "acme",
apiKey: process.env.BETTERCMS_API_KEY,
// baseUrl defaults to https://api.bettercms.ai/api/v1/delivery
revalidate: 60, // default ISR window (seconds); false = always fresh
});
// app/blog/[slug]/page.tsx
import { cms } from "@/lib/cms";
import { notFound } from "next/navigation";
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const entry = await cms.getEntry<{ title: string; body: string }>(slug, {
revalidate: 300,
// The exact tags the revalidation webhook sends on publish (see "Caching & revalidation").
tags: ["bcms:acme:all", `bcms:acme:entry:${slug}`],
});
if (!entry) notFound();
return <article><h1>{entry.fields.title}</h1>{/* ... */}</article>;
}
// app/blog/page.tsx — list, typed by your model registry
const { items, hasNextPage } = await cms.listEntries("blog", { page: 1, perPage: 20 });
Environment variables
createBetterCMS, createDraftModeRoute, createRevalidateRoute and <VisualEditing> read
no environment variables. You pass every value as an option, so the names in these examples
are only a convention. The package reads env in two places:
| Variable | Read by | Meaning |
|---|---|---|
BETTERCMS_API_KEY | bettercms-snapshot CLI | Project key that can read models and entries (required) |
BETTERCMS_WORKSPACE | bettercms-snapshot CLI | Workspace slug (required) |
BETTERCMS_API_URL | bettercms-snapshot CLI | API base, default https://api.bettercms.ai/api/v1 |
BETTERCMS_PREVIEW | getContent() | 1 reads live instead of bcms-content.json (preview builds) |
Revalidation secret. The value is the project's revalidation webhook secret (Project →
Settings → Revalidation). Store it as BCMS_REVALIDATE_SECRET: createRevalidateRoute() reads
that when no secret is passed. Without either, the route accepts unsigned requests.
BetterCMS-generated deploy workflow (connected repo). The repo secret is BCMS_API_KEY
(given only to the content-fetch step, never to the build) and the workspace is the repo
variable BCMS_WORKSPACE_SLUG. The build step gets PUBLIC_BCMS_API_URL,
PUBLIC_BCMS_WORKSPACE, PUBLIC_BCMS_PROJECT_ID and PUBLIC_SITE_URL. This package reads none
of them for you. Pass them into createBetterCMS yourself. They are server-only in Next unless
you re-export them with a NEXT_PUBLIC_ prefix.
API
createBetterCMS<Schema>(config)
workspace— workspace slug (required)apiKey— delivery API key (sent asAuthorization: Bearer)baseUrl/previewBaseUrl— override the API baserevalidate— default ISR window (seconds) orfalse
getEntry<TFields>(slug, opts?) → BetterCMSEntry<TFields> | null
Single published entry by content slug. null on 404.
Options: revalidate, tags, depth (0–2 ref hydration), select (field projection →
result is Partial<TFields>), preview + previewToken.
listEntries(model, opts?) → EntryList<Fields>
Published entries for a model, paginated. Throws BetterCMSError (CONTENT_NOT_FOUND)
for an unknown workspace/model; an empty model returns an empty page.
Options: page, perPage, revalidate, tags, depth, select.
Caching & revalidation
Reads flow through fetch, which Next patches into the data cache. Pass tags and call
revalidateTag(tag) on publish. revalidate: false with tags = cache-until-tag;
without tags = no-store (always fresh).
The revalidation webhook sends exactly two tags for each published or unpublished resource,
and createRevalidateRoute calls revalidateTag on each:
bcms:{workspace}:all: everything in the workspacebcms:{workspace}:{type}:{slug}, wheretypeisentryorpage
Tag reads with those strings (bcms:acme:entry:${slug}, bcms:acme:page:${slug},
bcms:acme:all for lists). The client does not add them for you, and a tag of any other shape
is never invalidated.
A deploy target in "revalidate" mode (a site hosted elsewhere) may point at the same route: it refreshes the paths and tags that call names, or the whole site when it names neither.
A converted page's values, live (bcmsPage)
A site @bettercms-ai/convert converted reads each page's values from bcms-content/<slug>.json.
bcmsPage reads the same page live, in the same shape, so the helper's bcms() reads it unchanged:
// app/page.tsx (Server Component)
import { bcmsPage } from "@bettercms-ai/next";
import { BcmsPage } from "@bettercms-ai/next/page";
import bcmsHomeSnapshot from "../bcms-content/home.json";
export default async function Page() {
const bcmsHome = await bcmsPage("home", bcmsHomeSnapshot);
return <BcmsPage value={bcmsHome}>{/* … */}</BcmsPage>;
}
// a "use client" file under it
import { useBcmsPage } from "@bettercms-ai/next/page";
const page = useBcmsPage();
In Draft Mode it reads the preview (/api/v1/preview/<slug>, uncached). Otherwise it reads the
published row from delivery, cached until a publish names it (bcms:{ws}:all,
bcms:{ws}:entry:{slug}, bcms:{ws}:page:{slug}), so the page stays static and
createRevalidateRoute refreshes it. When that read fails (an offline build), it falls back to the
published content file (BCMS_CONTENT_FILE), then to the snapshot you passed. Client components get
the same object through useBcmsPage(), so the server HTML and the hydrated page show the same copy.
Images from the CMS (next.config)
CMS images are remote URLs on cdn.bettercms.ai, which next/image refuses until
images.remotePatterns allows it. Wrap the config:
// next.config.ts
import { withBetterCMS } from "@bettercms-ai/next/config";
export default withBetterCMS(nextConfig);
It adds the media host (and NEXT_PUBLIC_BCMS_MEDIA_URL, when the site serves media from its
own) after your own patterns, once, and leaves the rest of the config as it is.
Draft preview
Drafts come only from the signed preview endpoint, through getEntry:
// app/api/draft/route.ts — validates the dashboard's preview token, enables Draft Mode,
// stores the token in a cookie, then redirects
import { createDraftModeRoute } from "@bettercms-ai/next";
export const { GET } = createDraftModeRoute({ apiUrl: "https://api.bettercms.ai" });
import { isDraftEnabled, getPreviewToken } from "@bettercms-ai/next";
const previewToken = (await isDraftEnabled()) ? await getPreviewToken() : null;
const entry = previewToken
? await cms.getEntry(slug, { preview: true, previewToken }) // uncached
: await cms.getEntry(slug, { tags: [`bcms:acme:entry:${slug}`] });
getPage has no draft option. It always returns the published page. To show a draft
page, use the token-scoped getEntry preview read above. For the Visual Editor canvas, wrap
the page's blocks in <BcmsDraftBridge> (@bettercms-ai/next/draft-bridge) instead of
<BcmsBlocks>. The editor pushes the draft block tree to it over postMessage, so the page
fetches nothing. Put <BcmsDraftMarker /> in the root layout for Draft Mode renders.
Static export (output: "export")
Run bettercms-snapshot before next build, and read content with getContent(config):
{ "scripts": { "build": "bettercms-snapshot && next build" } }
Without bcms-content.json, reads fall back to a live no-store fetch. That makes the routes
dynamic, and output: "export" silently drops them, leaving an empty out/. getContent turns
that into a loud SNAPSHOT_MISSING build error. Set BETTERCMS_PREVIEW=1 to read live in a
preview build instead.
llms.txt
// app/llms.txt/route.ts
import { llmsTxtRoute } from "@bettercms-ai/next";
export const GET = llmsTxtRoute({ models, title: "Acme", workspace: "acme" });
generateLlmsTxt(models, opts) (pure) and fetchModels({ apiUrl, apiKey }) are also exported.
Author-controlled values are escaped so content can't forge document structure.
Forms
Forms ship in your content snapshot (bcms-content.json → forms, written by the build
Action). Render one natively with <BcmsForm> — real inputs, no iframe, your own styles.
It handles conditional showIf fields, URL-query prefill, the honeypot, an optional
Turnstile widget, validation errors, and submission. No API key (the endpoint is
Turnstile-gated).
// app/contact/page.tsx — a Server Component reads the Action's snapshot...
import { getForm } from "@bettercms-ai/next";
import { ContactForm } from "./contact-form";
export default function ContactPage() {
const form = getForm("Contact"); // by name or id, from bcms-content.json
return form ? <ContactForm form={form} /> : null;
}
// ./contact-form.tsx — <BcmsForm> ships from its own "use client" entry.
import { BcmsForm } from "@bettercms-ai/next/form";
import type { DeliveryForm } from "@bettercms-ai/sdk";
export function ContactForm({ form }: { form: DeliveryForm }) {
return <BcmsForm form={form} turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />;
}
Not on React? Submit from anywhere with the framework-agnostic core:
import { submitForm } from "@bettercms-ai/sdk";
await submitForm({ formId, data: { email: "[email protected]" }, turnstileToken });
The dashboard's copy-paste embed snippet remains the zero-build fallback for non-CMS sites.
Page blocks (<BcmsBlocks>) — forms placed in the page builder
When you build a page in the BetterCMS dashboard and drop a Form block onto it, the
form renders inline exactly where you placed it. Render the page's blocks with
<BcmsBlocks> and pass the forms from readForms() — a form block resolves its
formId against them and renders <BcmsForm> for you. Every other block type
(heading, text, image, button, spacer, video, columns) renders to plain, class-driven
markup you can style.
🔴 Pass
components={readComponents()}too. Pages authored as SECTIONS — the shape the dashboard's "Add a section" picker and the AI both produce — are lists ofcomponentblocks. Acomponentblock whose definition was not delivered renders nothing, so a page can build green and come back showing only your layout's nav and footer. Both readers pull from the samebcms-content.jsonthe deploy Action writes, so this costs one extra call.
// app/[slug]/page.tsx
import { createBetterCMS, readForms, readComponents } from "@bettercms-ai/next";
import { BcmsBlocks } from "@bettercms-ai/next/blocks"; // its own "use client" entry
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
export default async function Page({ params }: { params: { slug: string } }) {
// The page's blocks come from the delivery API (`entry.blocks`); forms from the snapshot.
const page = await cms.getPage(params.slug);
const { forms, turnstileSiteKey } = readForms();
return (
<BcmsBlocks
blocks={page.blocks}
forms={forms}
// 🔴 NOT optional in practice. A page built out of SECTIONS is a list of `component`
// blocks, and a `component` block with no matching definition renders NOTHING. Omit
// this and such a page builds green, returns 200, and shows only your nav and footer.
components={readComponents()}
turnstileSiteKey={turnstileSiteKey ?? undefined}
/>
);
}
Sections in the CMS order (<BcmsSections>)
A page whose sections are written in its own file renders them in the order its CMS page lists them. A server component (no client JavaScript), with its own entry:
import { sectionsFor } from "@bettercms-ai/next";
import { BcmsSections, BcmsSection } from "@bettercms-ai/next/sections";
export default function Home() {
return (
<main>
<BcmsSections {...sectionsFor("home")}>
<BcmsSection bind="hero">{(root) => <Hero rootProps={root} />}</BcmsSection>
<BcmsSection bind="code:sec1"><LogoStrip /></BcmsSection>
<BcmsSection bind="features"><section className="features">…</section></BcmsSection>
</BcmsSections>
</main>
);
}
- A block bound to a section renders it at the block's position. A single DOM element gets
data-bcms-block,data-bcms-styleand the block's style cloned onto it. A component child is given a function instead: spreadrooton the element it renders. Without the function a component is stamped on a box:display:contentswhile the block has no style, and onedisplay:flow-rootbox carrying the style once it has (in a flex or grid parent the section is then one item). - A section added in the editor (a block with no bind) renders through
<BcmsBlocks>. - A section no block names:
code:<bind>always renders where you wrote it. A bare<bind>does too until the list names one of the page's sections; after that it is a removed section. blocksnull (no snapshot) renders every child in source order, untouched.
Pages Router: the page also renders in the browser, so the list must be a page prop:
export const getStaticProps = sectionsStaticProps("home"), then
<BcmsSections {...bcmsSections}> in a page that takes { bcmsSections }. A page with a data
function of its own passes it in: sectionsStaticProps("home", pageStaticProps) (or
sectionsServerSideProps) calls it and adds bcmsSections to the props it answers.
npx @bettercms-ai/convert --sections writes this wrap for you.
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 |
@bettercms-ai/next formats the number as it is typed. The formatter and its phone metadata
(~80 KB) live in a separate chunk that loads only when a form actually has a picker — a
form without one ships none of it. Until that chunk arrives the field is a plain tel input
inside the same bcms-phone wrapper, so your CSS applies before and after.
SEO — per-page <head> with generateMetadata
getPage() returns the page's metaTitle, metaDescription, and the rich metaJson
(OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Map them into
the route's <head> with buildMetadata — per-page values layer over your site defaults
(the same page-over-site precedence the live *.bettercms.site renderer uses):
// app/[slug]/page.tsx
import type { Metadata } from "next";
import { createBetterCMS, buildMetadata, resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/next";
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
// Your project-wide fallbacks (optional). Per-page meta always wins.
const siteDefaults: SiteSeoDefaults = {
metaDescription: "Selected work, experience, and contact information.",
ogImage: "https://example.com/og-default.png",
twitterHandle: "@acme",
};
export async function generateMetadata(
{ params }: { params: { slug: string } },
): Promise<Metadata> {
const page = await cms.getPage(params.slug);
if (!page) return {};
return buildMetadata(page, siteDefaults);
}
buildMetadata covers <title>, description, canonical, Open Graph, and Twitter.
JSON-LD isn't part of Next's Metadata, so emit it from the page component using
resolveSeo(...).jsonLd:
export default async function Page({ params }: { params: { slug: string } }) {
const page = await cms.getPage(params.slug);
if (!page) return null;
const { jsonLd } = resolveSeo(page, siteDefaults);
return (
<>
{jsonLd.map((node, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
/>
))}
{/* …render page.blocks… */}
</>
);
}
Keeping SEO fresh on publish
Reads are ISR-cached (revalidate, default 60s), so a SEO edit appears after the window
elapses. For instant refresh on publish, configure the project's revalidation
webhook (Project → Settings) to point at a createRevalidateRoute handler, passing the
webhook secret as secret. Tag your reads with the bcms:{workspace}:… tags from
"Caching & revalidation". cms.getPage(slug, { tags: [`bcms:acme:page:${slug}`] }) is the
page form. Without the webhook, edits still propagate, but on the ISR interval instead of
immediately.
License
UNLICENSED — internal BetterCMS package.