@betttercms/next
v0.5.0
Published
The BetterCMS adapter for Next.js — typed, cache-aware content reads (getEntry/listEntries), draft preview, build-time content snapshot, and llms.txt generation for the App Router.
Downloads
1,106
Readme
@betttercms/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 @betttercms/codegen: generate BetterCMSSchema from your
content models, then get fully-typed getEntry/listEntries with autocomplete.
Install
npm install @betttercms/next
# peers: next >= 14, react >= 18Quick start
// lib/cms.ts
import { createBetterCMS } from "@betttercms/next";
import type { BetterCMSSchema } from "./bettercms.generated"; // from @betttercms/codegen
export const cms = createBetterCMS<BetterCMSSchema>({
workspace: "acme",
apiKey: process.env.BETTERCMS_DELIVERY_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,
tags: [`post:${slug}`], // invalidate on publish via revalidateTag(`post:${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 });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).
Draft preview
import { isDraftEnabled } from "@betttercms/next";
const preview = await isDraftEnabled(); // wraps next/headers draftMode()
const entry = await cms.getEntry(slug, { preview, previewToken: token });llms.txt
// app/llms.txt/route.ts
import { llmsTxtRoute } from "@betttercms/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 "@betttercms/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 "@betttercms/next/form";
import type { DeliveryForm } from "@betttercms/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 "@betttercms/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.
// app/[slug]/page.tsx
import { createBetterCMS, readForms } from "@betttercms/next";
import { BcmsBlocks } from "@betttercms/next/blocks"; // its own "use client" entry
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BCMS_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}
turnstileSiteKey={turnstileSiteKey ?? undefined}
/>
);
}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 "@betttercms/next";
const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BCMS_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 and pair
your reads with tags + revalidateTag. Without it, edits still propagate — just on the
ISR interval, not immediately.
License
UNLICENSED — internal BetterCMS package.
