@bettercms-ai/next

中文文档
0.39.0发布于 2026/11/05TypeScriptESM + CJS近 30 天下载 5106

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. The /blocks, /form, /field, /search and /visual-editing entries are framew

原始使用文档
文档目录20 节
  1. Install
  2. Quick start
  3. Environment variables
  4. API
  5. createBetterCMS<Schema>(config)
  6. getEntry<TFields>(slug, opts?) → BetterCMSEntry<TFields> | …
  7. listEntries(model, opts?) → EntryList<Fields>
  8. Caching & revalidation
  9. A converted page's values, live ( bcmsPage )
  10. Images from the CMS ( next.config )
  11. Draft preview
  12. Static export ( output: "export" )
  13. llms.txt
  14. Forms
  15. Page blocks ( <BcmsBlocks> ) — forms placed in the page bui…
  16. Sections in the CMS order ( <BcmsSections> )
  17. Phone fields with a country code picker
  18. SEO — per-page <head> with generateMetadata
  19. Keeping SEO fresh on publish
  20. License

@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:

VariableRead byMeaning
BETTERCMS_API_KEYbettercms-snapshot CLIProject key that can read models and entries (required)
BETTERCMS_WORKSPACEbettercms-snapshot CLIWorkspace slug (required)
BETTERCMS_API_URLbettercms-snapshot CLIAPI base, default https://api.bettercms.ai/api/v1
BETTERCMS_PREVIEWgetContent()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 as Authorization: Bearer)
  • baseUrl / previewBaseUrl — override the API base
  • revalidate — default ISR window (seconds) or false

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 workspace
  • bcms:{workspace}:{type}:{slug}, where type is entry or page

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 of component blocks. A component block 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 same bcms-content.json the 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-style and the block's style cloned onto it. A component child is given a function instead: spread root on the element it renders. Without the function a component is stamped on a box: display:contents while the block has no style, and one display:flow-root box 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.
  • blocks null (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:

ClassElement
bcms-phonethe wrapper around the country control and the number
bcms-phone-countrythe 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.

版本与发布记录

@bettercms-ai/next 共发布 61 个版本,首次发布于 2026/06/30;过去 12 个月发布了 61 个版本。

版本发布日期距今
0.39.0latest2026/11/052 天前
0.38.02026/11/026 天前
0.36.32026/10/2712 天前
0.36.12026/10/2613 天前
0.36.22026/10/2613 天前
0.36.02026/10/2613 天前

关于 @bettercms-ai/next 的常见问题

@bettercms-ai/next 是什么?

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. The /blocks, /form, /field, /search and /visual-editing entries are framew

如何安装 @bettercms-ai/next?

在项目目录运行 npm install @bettercms-ai/next,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 bettercms-snapshot,可以用 npx bettercms-snapshot 直接运行。

@bettercms-ai/next 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@bettercms-ai/next 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@bettercms-ai/next 可以商用吗?

@bettercms-ai/next 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@bettercms-ai/next 的下载量有多少?

根据 npm 官方下载统计,@bettercms-ai/next 在 2026/10/08 – 2026/11/06 这 30 天内被下载 5,106 次,日均约 170 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@bettercms-ai/next 还在维护吗?

最新版本 0.39.0 发布于 2026/11/05(2 天前);至今共发布 61 个版本,其中过去 12 个月发布了 61 个;首次发布于 2026/06/30。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。