@bettercms-ai/next 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,321 +1,361 @@
1
- # @bettercms-ai/next
2
-
3
- The BetterCMS adapter for **Next.js** (App Router). Typed, cache-aware content reads,
4
- draft preview, and `llms.txt` generation — the deterministic floor under your CMS.
5
-
6
- Pairs with [`@bettercms-ai/codegen`](../codegen): generate `BetterCMSSchema` from your
7
- content models, then get fully-typed `getEntry`/`listEntries` with autocomplete.
8
-
9
- ## Install
10
-
11
- ```bash
12
- npm install @bettercms-ai/next
13
- # peers: next >= 14, react >= 18
14
- ```
15
-
16
- ## Quick start
17
-
18
- ```ts
19
- // lib/cms.ts
20
- import { createBetterCMS } from "@bettercms-ai/next";
21
- import type { BetterCMSSchema } from "./bettercms.generated"; // from @bettercms-ai/codegen
22
-
23
- export const cms = createBetterCMS<BetterCMSSchema>({
24
- workspace: "acme",
25
- apiKey: process.env.BETTERCMS_API_KEY,
26
- // baseUrl defaults to https://api.bettercms.ai/api/v1/delivery
27
- revalidate: 60, // default ISR window (seconds); false = always fresh
28
- });
29
- ```
30
-
31
- ```tsx
32
- // app/blog/[slug]/page.tsx
33
- import { cms } from "@/lib/cms";
34
- import { notFound } from "next/navigation";
35
-
36
- export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
37
- const { slug } = await params;
38
- const entry = await cms.getEntry<{ title: string; body: string }>(slug, {
39
- revalidate: 300,
40
- // The exact tags the revalidation webhook sends on publish (see "Caching & revalidation").
41
- tags: ["bcms:acme:all", `bcms:acme:entry:${slug}`],
42
- });
43
- if (!entry) notFound();
44
- return <article><h1>{entry.fields.title}</h1>{/* ... */}</article>;
45
- }
46
- ```
47
-
48
- ```tsx
49
- // app/blog/page.tsx — list, typed by your model registry
50
- const { items, hasNextPage } = await cms.listEntries("blog", { page: 1, perPage: 20 });
51
- ```
52
-
53
- ## Environment variables
54
-
55
- `createBetterCMS`, `createDraftModeRoute`, `createRevalidateRoute` and `<VisualEditing>` read
56
- **no** environment variables. You pass every value as an option, so the names in these examples
57
- are only a convention. The package reads env in two places:
58
-
59
- | Variable | Read by | Meaning |
60
- | --- | --- | --- |
61
- | `BETTERCMS_API_KEY` | `bettercms-snapshot` CLI | Project key that can read models and entries (required) |
62
- | `BETTERCMS_WORKSPACE` | `bettercms-snapshot` CLI | Workspace slug (required) |
63
- | `BETTERCMS_API_URL` | `bettercms-snapshot` CLI | API base, default `https://api.bettercms.ai/api/v1` |
64
- | `BETTERCMS_PREVIEW` | `getContent()` | `1` reads live instead of `bcms-content.json` (preview builds) |
65
-
66
- **Revalidation secret.** The value is the project's revalidation webhook secret (Project →
67
- Settings). The dashboard tells you to store it as `BETTERCMS_WEBHOOK_SECRET`, and that is the
68
- value `createRevalidateRoute({ secret })` needs. `BCMS_REVALIDATE_SECRET` in the route's source
69
- comment is the same value under another name. Without `secret` the route accepts unsigned
70
- requests, so always pass it.
71
-
72
- **BetterCMS-generated deploy workflow** (connected repo). The repo secret is `BCMS_API_KEY`
73
- (given only to the content-fetch step, never to the build) and the workspace is the repo
74
- variable `BCMS_WORKSPACE_SLUG`. The build step gets `PUBLIC_BCMS_API_URL`,
75
- `PUBLIC_BCMS_WORKSPACE`, `PUBLIC_BCMS_PROJECT_ID` and `PUBLIC_SITE_URL`. This package reads none
76
- of them for you. Pass them into `createBetterCMS` yourself. They are server-only in Next unless
77
- you re-export them with a `NEXT_PUBLIC_` prefix.
78
-
79
- ## API
80
-
81
- ### `createBetterCMS<Schema>(config)`
82
- - `workspace` — workspace slug (required)
83
- - `apiKey` — delivery API key (sent as `Authorization: Bearer`)
84
- - `baseUrl` / `previewBaseUrl` — override the API base
85
- - `revalidate` — default ISR window (seconds) or `false`
86
-
87
- ### `getEntry<TFields>(slug, opts?)` → `BetterCMSEntry<TFields> | null`
88
- Single published entry by content slug. `null` on 404.
89
- Options: `revalidate`, `tags`, `depth` (0–2 ref hydration), `select` (field projection →
90
- result is `Partial<TFields>`), `preview` + `previewToken`.
91
-
92
- ### `listEntries(model, opts?)` → `EntryList<Fields>`
93
- Published entries for a model, paginated. **Throws** `BetterCMSError` (CONTENT_NOT_FOUND)
94
- for an unknown workspace/model; an empty model returns an empty page.
95
- Options: `page`, `perPage`, `revalidate`, `tags`, `depth`, `select`.
96
-
97
- ### Caching & revalidation
98
- Reads flow through `fetch`, which Next patches into the data cache. Pass `tags` and call
99
- `revalidateTag(tag)` on publish. `revalidate: false` **with** tags = cache-until-tag;
100
- without tags = `no-store` (always fresh).
101
-
102
- The revalidation webhook sends exactly two tags for each published or unpublished resource,
103
- and `createRevalidateRoute` calls `revalidateTag` on each:
104
-
105
- - `bcms:{workspace}:all`: everything in the workspace
106
- - `bcms:{workspace}:{type}:{slug}`, where `type` is `entry` or `page`
107
-
108
- Tag reads with those strings (`bcms:acme:entry:${slug}`, `bcms:acme:page:${slug}`,
109
- `bcms:acme:all` for lists). The client does not add them for you, and a tag of any other shape
110
- is never invalidated.
111
-
112
- ### Draft preview
113
- Drafts come only from the signed preview endpoint, through `getEntry`:
114
-
115
- ```ts
116
- // app/api/draft/route.ts — validates the dashboard's preview token, enables Draft Mode,
117
- // stores the token in a cookie, then redirects
118
- import { createDraftModeRoute } from "@bettercms-ai/next";
119
- export const { GET } = createDraftModeRoute({ apiUrl: "https://api.bettercms.ai" });
120
- ```
121
-
122
- ```ts
123
- import { isDraftEnabled, getPreviewToken } from "@bettercms-ai/next";
124
- const previewToken = (await isDraftEnabled()) ? await getPreviewToken() : null;
125
- const entry = previewToken
126
- ? await cms.getEntry(slug, { preview: true, previewToken }) // uncached
127
- : await cms.getEntry(slug, { tags: [`bcms:acme:entry:${slug}`] });
128
- ```
129
-
130
- `getPage` has **no draft option**. It always returns the published page. To show a draft
131
- page, use the token-scoped `getEntry` preview read above. For the Visual Editor canvas, wrap
132
- the page's blocks in `<BcmsDraftBridge>` (`@bettercms-ai/next/draft-bridge`) instead of
133
- `<BcmsBlocks>`. The editor pushes the draft block tree to it over `postMessage`, so the page
134
- fetches nothing. Put `<BcmsDraftMarker />` in the root layout for Draft Mode renders.
135
-
136
- ### Static export (`output: "export"`)
137
- Run `bettercms-snapshot` **before** `next build`, and read content with `getContent(config)`:
138
-
139
- ```json
140
- { "scripts": { "build": "bettercms-snapshot && next build" } }
141
- ```
142
-
143
- Without `bcms-content.json`, reads fall back to a live `no-store` fetch. That makes the routes
144
- dynamic, and `output: "export"` silently drops them, leaving an empty `out/`. `getContent` turns
145
- that into a loud `SNAPSHOT_MISSING` build error. Set `BETTERCMS_PREVIEW=1` to read live in a
146
- preview build instead.
147
-
148
- ### `llms.txt`
149
- ```ts
150
- // app/llms.txt/route.ts
151
- import { llmsTxtRoute } from "@bettercms-ai/next";
152
- export const GET = llmsTxtRoute({ models, title: "Acme", workspace: "acme" });
153
- ```
154
- `generateLlmsTxt(models, opts)` (pure) and `fetchModels({ apiUrl, apiKey })` are also exported.
155
- Author-controlled values are escaped so content can't forge document structure.
156
-
157
- ### Forms
158
-
159
- Forms ship in your content snapshot (`bcms-content.json` → `forms`, written by the build
160
- Action). Render one natively with `<BcmsForm>` — real inputs, no iframe, your own styles.
161
- It handles conditional `showIf` fields, URL-query prefill, the honeypot, an optional
162
- Turnstile widget, validation errors, and submission. No API key (the endpoint is
163
- Turnstile-gated).
164
-
165
- ```tsx
166
- // app/contact/page.tsx — a Server Component reads the Action's snapshot...
167
- import { getForm } from "@bettercms-ai/next";
168
- import { ContactForm } from "./contact-form";
169
-
170
- export default function ContactPage() {
171
- const form = getForm("Contact"); // by name or id, from bcms-content.json
172
- return form ? <ContactForm form={form} /> : null;
173
- }
174
- ```
175
-
176
- ```tsx
177
- // ./contact-form.tsx — <BcmsForm> ships from its own "use client" entry.
178
- import { BcmsForm } from "@bettercms-ai/next/form";
179
- import type { DeliveryForm } from "@bettercms-ai/sdk";
180
-
181
- export function ContactForm({ form }: { form: DeliveryForm }) {
182
- return <BcmsForm form={form} turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />;
183
- }
184
- ```
185
-
186
- Not on React? Submit from anywhere with the framework-agnostic core:
187
-
188
- ```ts
189
- import { submitForm } from "@bettercms-ai/sdk";
190
- await submitForm({ formId, data: { email: "a@b.com" }, turnstileToken });
191
- ```
192
-
193
- The dashboard's copy-paste embed snippet remains the zero-build fallback for non-CMS sites.
194
-
195
- ### Page blocks (`<BcmsBlocks>`) — forms placed in the page builder
196
-
197
- When you build a page in the BetterCMS dashboard and drop a **Form block** onto it, the
198
- form renders inline exactly where you placed it. Render the page's blocks with
199
- `<BcmsBlocks>` and pass the forms from `readForms()` — a `form` block resolves its
200
- `formId` against them and renders `<BcmsForm>` for you. Every other block type
201
- (heading, text, image, button, spacer, video, columns) renders to plain, class-driven
202
- markup you can style.
203
-
204
- > 🔴 **Pass `components={readComponents()}` too.** Pages authored as SECTIONS — the shape the
205
- > dashboard's "Add a section" picker and the AI both produce — are lists of `component` blocks.
206
- > A `component` block whose definition was not delivered renders **nothing**, so a page can
207
- > build green and come back showing only your layout's nav and footer. Both readers pull from
208
- > the same `bcms-content.json` the deploy Action writes, so this costs one extra call.
209
-
210
- ```tsx
211
- // app/[slug]/page.tsx
212
- import { createBetterCMS, readForms, readComponents } from "@bettercms-ai/next";
213
- import { BcmsBlocks } from "@bettercms-ai/next/blocks"; // its own "use client" entry
214
-
215
- const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
216
-
217
- export default async function Page({ params }: { params: { slug: string } }) {
218
- // The page's blocks come from the delivery API (`entry.blocks`); forms from the snapshot.
219
- const page = await cms.getPage(params.slug);
220
- const { forms, turnstileSiteKey } = readForms();
221
- return (
222
- <BcmsBlocks
223
- blocks={page.blocks}
224
- forms={forms}
225
- // 🔴 NOT optional in practice. A page built out of SECTIONS is a list of `component`
226
- // blocks, and a `component` block with no matching definition renders NOTHING. Omit
227
- // this and such a page builds green, returns 200, and shows only your nav and footer.
228
- components={readComponents()}
229
- turnstileSiteKey={turnstileSiteKey ?? undefined}
230
- />
231
- );
232
- }
233
- ```
234
-
235
-
236
- ### Phone fields with a country code picker
237
-
238
- A phone field whose author enabled **Country code picker** in the builder renders a country
239
- control beside the number. Whatever the visitor picks, the field still submits **one value**,
240
- under its own key, in E.164 (`+14155550123`) — there is no second key to handle.
241
-
242
- Two classes are yours to style:
243
-
244
- | Class | Element |
245
- |---|---|
246
- | `bcms-phone` | the wrapper around the country control and the number |
247
- | `bcms-phone-country` | the country control itself |
248
-
249
- `@bettercms-ai/next` formats the number as it is typed. The formatter and its phone metadata
250
- (~80&nbsp;KB) live in a **separate chunk that loads only when a form actually has a picker** — a
251
- form without one ships none of it. Until that chunk arrives the field is a plain `tel` input
252
- inside the same `bcms-phone` wrapper, so your CSS applies before and after.
253
-
254
-
255
- ## SEO — per-page `<head>` with `generateMetadata`
256
-
257
- `getPage()` returns the page's `metaTitle`, `metaDescription`, and the rich `metaJson`
258
- (OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Map them into
259
- the route's `<head>` with `buildMetadata` — per-page values layer over your site defaults
260
- (the same page-over-site precedence the live `*.bettercms.site` renderer uses):
261
-
262
- ```tsx
263
- // app/[slug]/page.tsx
264
- import type { Metadata } from "next";
265
- import { createBetterCMS, buildMetadata, resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/next";
266
-
267
- const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
268
-
269
- // Your project-wide fallbacks (optional). Per-page meta always wins.
270
- const siteDefaults: SiteSeoDefaults = {
271
- metaDescription: "Selected work, experience, and contact information.",
272
- ogImage: "https://example.com/og-default.png",
273
- twitterHandle: "@acme",
274
- };
275
-
276
- export async function generateMetadata(
277
- { params }: { params: { slug: string } },
278
- ): Promise<Metadata> {
279
- const page = await cms.getPage(params.slug);
280
- if (!page) return {};
281
- return buildMetadata(page, siteDefaults);
282
- }
283
- ```
284
-
285
- `buildMetadata` covers `<title>`, description, canonical, Open Graph, and Twitter.
286
- JSON-LD isn't part of Next's `Metadata`, so emit it from the page component using
287
- `resolveSeo(...).jsonLd`:
288
-
289
- ```tsx
290
- export default async function Page({ params }: { params: { slug: string } }) {
291
- const page = await cms.getPage(params.slug);
292
- if (!page) return null;
293
- const { jsonLd } = resolveSeo(page, siteDefaults);
294
- return (
295
- <>
296
- {jsonLd.map((node, i) => (
297
- <script
298
- key={i}
299
- type="application/ld+json"
300
- dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
301
- />
302
- ))}
303
- {/* …render page.blocks… */}
304
- </>
305
- );
306
- }
307
- ```
308
-
309
- ### Keeping SEO fresh on publish
310
-
311
- Reads are ISR-cached (`revalidate`, default 60s), so a SEO edit appears after the window
312
- elapses. For **instant** refresh on publish, configure the project's **revalidation
313
- webhook** (Project → Settings) to point at a `createRevalidateRoute` handler, passing the
314
- webhook secret as `secret`. Tag your reads with the `bcms:{workspace}:…` tags from
315
- "Caching & revalidation". `` cms.getPage(slug, { tags: [`bcms:acme:page:${slug}`] }) `` is the
316
- page form. Without the webhook, edits still propagate, but on the ISR interval instead of
317
- immediately.
318
-
319
- ## License
320
-
321
- UNLICENSED — internal BetterCMS package.
1
+ # @bettercms-ai/next
2
+
3
+ The BetterCMS adapter for **Next.js** (App Router). Typed, cache-aware content reads,
4
+ draft preview, and `llms.txt` generation — the deterministic floor under your CMS.
5
+
6
+ Pairs with [`@bettercms-ai/codegen`](../codegen): generate `BetterCMSSchema` from your
7
+ content models, then get fully-typed `getEntry`/`listEntries` with autocomplete.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @bettercms-ai/next
13
+ # peers: next >= 14, react >= 18
14
+ ```
15
+
16
+ ## Quick start
17
+
18
+ ```ts
19
+ // lib/cms.ts
20
+ import { createBetterCMS } from "@bettercms-ai/next";
21
+ import type { BetterCMSSchema } from "./bettercms.generated"; // from @bettercms-ai/codegen
22
+
23
+ export const cms = createBetterCMS<BetterCMSSchema>({
24
+ workspace: "acme",
25
+ apiKey: process.env.BETTERCMS_API_KEY,
26
+ // baseUrl defaults to https://api.bettercms.ai/api/v1/delivery
27
+ revalidate: 60, // default ISR window (seconds); false = always fresh
28
+ });
29
+ ```
30
+
31
+ ```tsx
32
+ // app/blog/[slug]/page.tsx
33
+ import { cms } from "@/lib/cms";
34
+ import { notFound } from "next/navigation";
35
+
36
+ export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
37
+ const { slug } = await params;
38
+ const entry = await cms.getEntry<{ title: string; body: string }>(slug, {
39
+ revalidate: 300,
40
+ // The exact tags the revalidation webhook sends on publish (see "Caching & revalidation").
41
+ tags: ["bcms:acme:all", `bcms:acme:entry:${slug}`],
42
+ });
43
+ if (!entry) notFound();
44
+ return <article><h1>{entry.fields.title}</h1>{/* ... */}</article>;
45
+ }
46
+ ```
47
+
48
+ ```tsx
49
+ // app/blog/page.tsx — list, typed by your model registry
50
+ const { items, hasNextPage } = await cms.listEntries("blog", { page: 1, perPage: 20 });
51
+ ```
52
+
53
+ ## Environment variables
54
+
55
+ `createBetterCMS`, `createDraftModeRoute`, `createRevalidateRoute` and `<VisualEditing>` read
56
+ **no** environment variables. You pass every value as an option, so the names in these examples
57
+ are only a convention. The package reads env in two places:
58
+
59
+ | Variable | Read by | Meaning |
60
+ | --- | --- | --- |
61
+ | `BETTERCMS_API_KEY` | `bettercms-snapshot` CLI | Project key that can read models and entries (required) |
62
+ | `BETTERCMS_WORKSPACE` | `bettercms-snapshot` CLI | Workspace slug (required) |
63
+ | `BETTERCMS_API_URL` | `bettercms-snapshot` CLI | API base, default `https://api.bettercms.ai/api/v1` |
64
+ | `BETTERCMS_PREVIEW` | `getContent()` | `1` reads live instead of `bcms-content.json` (preview builds) |
65
+
66
+ **Revalidation secret.** The value is the project's revalidation webhook secret (Project →
67
+ Settings). The dashboard tells you to store it as `BETTERCMS_WEBHOOK_SECRET`, and that is the
68
+ value `createRevalidateRoute({ secret })` needs. `BCMS_REVALIDATE_SECRET` in the route's source
69
+ comment is the same value under another name. Without `secret` the route accepts unsigned
70
+ requests, so always pass it.
71
+
72
+ **BetterCMS-generated deploy workflow** (connected repo). The repo secret is `BCMS_API_KEY`
73
+ (given only to the content-fetch step, never to the build) and the workspace is the repo
74
+ variable `BCMS_WORKSPACE_SLUG`. The build step gets `PUBLIC_BCMS_API_URL`,
75
+ `PUBLIC_BCMS_WORKSPACE`, `PUBLIC_BCMS_PROJECT_ID` and `PUBLIC_SITE_URL`. This package reads none
76
+ of them for you. Pass them into `createBetterCMS` yourself. They are server-only in Next unless
77
+ you re-export them with a `NEXT_PUBLIC_` prefix.
78
+
79
+ ## API
80
+
81
+ ### `createBetterCMS<Schema>(config)`
82
+ - `workspace` — workspace slug (required)
83
+ - `apiKey` — delivery API key (sent as `Authorization: Bearer`)
84
+ - `baseUrl` / `previewBaseUrl` — override the API base
85
+ - `revalidate` — default ISR window (seconds) or `false`
86
+
87
+ ### `getEntry<TFields>(slug, opts?)` → `BetterCMSEntry<TFields> | null`
88
+ Single published entry by content slug. `null` on 404.
89
+ Options: `revalidate`, `tags`, `depth` (0–2 ref hydration), `select` (field projection →
90
+ result is `Partial<TFields>`), `preview` + `previewToken`.
91
+
92
+ ### `listEntries(model, opts?)` → `EntryList<Fields>`
93
+ Published entries for a model, paginated. **Throws** `BetterCMSError` (CONTENT_NOT_FOUND)
94
+ for an unknown workspace/model; an empty model returns an empty page.
95
+ Options: `page`, `perPage`, `revalidate`, `tags`, `depth`, `select`.
96
+
97
+ ### Caching & revalidation
98
+ Reads flow through `fetch`, which Next patches into the data cache. Pass `tags` and call
99
+ `revalidateTag(tag)` on publish. `revalidate: false` **with** tags = cache-until-tag;
100
+ without tags = `no-store` (always fresh).
101
+
102
+ The revalidation webhook sends exactly two tags for each published or unpublished resource,
103
+ and `createRevalidateRoute` calls `revalidateTag` on each:
104
+
105
+ - `bcms:{workspace}:all`: everything in the workspace
106
+ - `bcms:{workspace}:{type}:{slug}`, where `type` is `entry` or `page`
107
+
108
+ Tag reads with those strings (`bcms:acme:entry:${slug}`, `bcms:acme:page:${slug}`,
109
+ `bcms:acme:all` for lists). The client does not add them for you, and a tag of any other shape
110
+ is never invalidated.
111
+
112
+ ### Draft preview
113
+ Drafts come only from the signed preview endpoint, through `getEntry`:
114
+
115
+ ```ts
116
+ // app/api/draft/route.ts — validates the dashboard's preview token, enables Draft Mode,
117
+ // stores the token in a cookie, then redirects
118
+ import { createDraftModeRoute } from "@bettercms-ai/next";
119
+ export const { GET } = createDraftModeRoute({ apiUrl: "https://api.bettercms.ai" });
120
+ ```
121
+
122
+ ```ts
123
+ import { isDraftEnabled, getPreviewToken } from "@bettercms-ai/next";
124
+ const previewToken = (await isDraftEnabled()) ? await getPreviewToken() : null;
125
+ const entry = previewToken
126
+ ? await cms.getEntry(slug, { preview: true, previewToken }) // uncached
127
+ : await cms.getEntry(slug, { tags: [`bcms:acme:entry:${slug}`] });
128
+ ```
129
+
130
+ `getPage` has **no draft option**. It always returns the published page. To show a draft
131
+ page, use the token-scoped `getEntry` preview read above. For the Visual Editor canvas, wrap
132
+ the page's blocks in `<BcmsDraftBridge>` (`@bettercms-ai/next/draft-bridge`) instead of
133
+ `<BcmsBlocks>`. The editor pushes the draft block tree to it over `postMessage`, so the page
134
+ fetches nothing. Put `<BcmsDraftMarker />` in the root layout for Draft Mode renders.
135
+
136
+ ### Static export (`output: "export"`)
137
+ Run `bettercms-snapshot` **before** `next build`, and read content with `getContent(config)`:
138
+
139
+ ```json
140
+ { "scripts": { "build": "bettercms-snapshot && next build" } }
141
+ ```
142
+
143
+ Without `bcms-content.json`, reads fall back to a live `no-store` fetch. That makes the routes
144
+ dynamic, and `output: "export"` silently drops them, leaving an empty `out/`. `getContent` turns
145
+ that into a loud `SNAPSHOT_MISSING` build error. Set `BETTERCMS_PREVIEW=1` to read live in a
146
+ preview build instead.
147
+
148
+ ### `llms.txt`
149
+ ```ts
150
+ // app/llms.txt/route.ts
151
+ import { llmsTxtRoute } from "@bettercms-ai/next";
152
+ export const GET = llmsTxtRoute({ models, title: "Acme", workspace: "acme" });
153
+ ```
154
+ `generateLlmsTxt(models, opts)` (pure) and `fetchModels({ apiUrl, apiKey })` are also exported.
155
+ Author-controlled values are escaped so content can't forge document structure.
156
+
157
+ ### Forms
158
+
159
+ Forms ship in your content snapshot (`bcms-content.json` → `forms`, written by the build
160
+ Action). Render one natively with `<BcmsForm>` — real inputs, no iframe, your own styles.
161
+ It handles conditional `showIf` fields, URL-query prefill, the honeypot, an optional
162
+ Turnstile widget, validation errors, and submission. No API key (the endpoint is
163
+ Turnstile-gated).
164
+
165
+ ```tsx
166
+ // app/contact/page.tsx — a Server Component reads the Action's snapshot...
167
+ import { getForm } from "@bettercms-ai/next";
168
+ import { ContactForm } from "./contact-form";
169
+
170
+ export default function ContactPage() {
171
+ const form = getForm("Contact"); // by name or id, from bcms-content.json
172
+ return form ? <ContactForm form={form} /> : null;
173
+ }
174
+ ```
175
+
176
+ ```tsx
177
+ // ./contact-form.tsx — <BcmsForm> ships from its own "use client" entry.
178
+ import { BcmsForm } from "@bettercms-ai/next/form";
179
+ import type { DeliveryForm } from "@bettercms-ai/sdk";
180
+
181
+ export function ContactForm({ form }: { form: DeliveryForm }) {
182
+ return <BcmsForm form={form} turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />;
183
+ }
184
+ ```
185
+
186
+ Not on React? Submit from anywhere with the framework-agnostic core:
187
+
188
+ ```ts
189
+ import { submitForm } from "@bettercms-ai/sdk";
190
+ await submitForm({ formId, data: { email: "a@b.com" }, turnstileToken });
191
+ ```
192
+
193
+ The dashboard's copy-paste embed snippet remains the zero-build fallback for non-CMS sites.
194
+
195
+ ### Page blocks (`<BcmsBlocks>`) — forms placed in the page builder
196
+
197
+ When you build a page in the BetterCMS dashboard and drop a **Form block** onto it, the
198
+ form renders inline exactly where you placed it. Render the page's blocks with
199
+ `<BcmsBlocks>` and pass the forms from `readForms()` — a `form` block resolves its
200
+ `formId` against them and renders `<BcmsForm>` for you. Every other block type
201
+ (heading, text, image, button, spacer, video, columns) renders to plain, class-driven
202
+ markup you can style.
203
+
204
+ > 🔴 **Pass `components={readComponents()}` too.** Pages authored as SECTIONS — the shape the
205
+ > dashboard's "Add a section" picker and the AI both produce — are lists of `component` blocks.
206
+ > A `component` block whose definition was not delivered renders **nothing**, so a page can
207
+ > build green and come back showing only your layout's nav and footer. Both readers pull from
208
+ > the same `bcms-content.json` the deploy Action writes, so this costs one extra call.
209
+
210
+ ```tsx
211
+ // app/[slug]/page.tsx
212
+ import { createBetterCMS, readForms, readComponents } from "@bettercms-ai/next";
213
+ import { BcmsBlocks } from "@bettercms-ai/next/blocks"; // its own "use client" entry
214
+
215
+ const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
216
+
217
+ export default async function Page({ params }: { params: { slug: string } }) {
218
+ // The page's blocks come from the delivery API (`entry.blocks`); forms from the snapshot.
219
+ const page = await cms.getPage(params.slug);
220
+ const { forms, turnstileSiteKey } = readForms();
221
+ return (
222
+ <BcmsBlocks
223
+ blocks={page.blocks}
224
+ forms={forms}
225
+ // 🔴 NOT optional in practice. A page built out of SECTIONS is a list of `component`
226
+ // blocks, and a `component` block with no matching definition renders NOTHING. Omit
227
+ // this and such a page builds green, returns 200, and shows only your nav and footer.
228
+ components={readComponents()}
229
+ turnstileSiteKey={turnstileSiteKey ?? undefined}
230
+ />
231
+ );
232
+ }
233
+ ```
234
+
235
+
236
+ ### Sections in the CMS order (`<BcmsSections>`)
237
+
238
+ A page whose sections are written in its own file renders them in the order its CMS page lists
239
+ them. A server component (no client JavaScript), with its own entry:
240
+
241
+ ```tsx
242
+ import { sectionsFor } from "@bettercms-ai/next";
243
+ import { BcmsSections, BcmsSection } from "@bettercms-ai/next/sections";
244
+
245
+ export default function Home() {
246
+ return (
247
+ <main>
248
+ <BcmsSections {...sectionsFor("home")}>
249
+ <BcmsSection bind="hero">{(root) => <Hero rootProps={root} />}</BcmsSection>
250
+ <BcmsSection bind="code:sec1"><LogoStrip /></BcmsSection>
251
+ <BcmsSection bind="features"><section className="features">…</section></BcmsSection>
252
+ </BcmsSections>
253
+ </main>
254
+ );
255
+ }
256
+ ```
257
+
258
+ - A block bound to a section renders it at the block's position. A single DOM element gets
259
+ `data-bcms-block`, `data-bcms-style` and the block's style cloned onto it. A component child is
260
+ given a function instead: spread `root` on the element it renders. Without the function a
261
+ component is stamped on a box: `display:contents` while the block has no style, and one
262
+ `display:flow-root` box carrying the style once it has (in a flex or grid parent the section is
263
+ then one item).
264
+ - A section added in the editor (a block with no bind) renders through `<BcmsBlocks>`.
265
+ - A section no block names: `code:<bind>` always renders where you wrote it. A bare `<bind>` does
266
+ too until the list names one of the page's sections; after that it is a removed section.
267
+ - `blocks` null (no snapshot) renders every child in source order, untouched.
268
+
269
+ Pages Router: the page also renders in the browser, so the list must be a page prop:
270
+ `export const getStaticProps = sectionsStaticProps("home")`, then
271
+ `<BcmsSections {...bcmsSections}>` in a page that takes `{ bcmsSections }`. A page with a data
272
+ function of its own passes it in: `sectionsStaticProps("home", pageStaticProps)` (or
273
+ `sectionsServerSideProps`) calls it and adds `bcmsSections` to the props it answers.
274
+ `npx @bettercms-ai/convert --sections` writes this wrap for you.
275
+
276
+ ### Phone fields with a country code picker
277
+
278
+ A phone field whose author enabled **Country code picker** in the builder renders a country
279
+ control beside the number. Whatever the visitor picks, the field still submits **one value**,
280
+ under its own key, in E.164 (`+14155550123`) — there is no second key to handle.
281
+
282
+ Two classes are yours to style:
283
+
284
+ | Class | Element |
285
+ |---|---|
286
+ | `bcms-phone` | the wrapper around the country control and the number |
287
+ | `bcms-phone-country` | the country control itself |
288
+
289
+ `@bettercms-ai/next` formats the number as it is typed. The formatter and its phone metadata
290
+ (~80&nbsp;KB) live in a **separate chunk that loads only when a form actually has a picker** — a
291
+ form without one ships none of it. Until that chunk arrives the field is a plain `tel` input
292
+ inside the same `bcms-phone` wrapper, so your CSS applies before and after.
293
+
294
+
295
+ ## SEO — per-page `<head>` with `generateMetadata`
296
+
297
+ `getPage()` returns the page's `metaTitle`, `metaDescription`, and the rich `metaJson`
298
+ (OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Map them into
299
+ the route's `<head>` with `buildMetadata` — per-page values layer over your site defaults
300
+ (the same page-over-site precedence the live `*.bettercms.site` renderer uses):
301
+
302
+ ```tsx
303
+ // app/[slug]/page.tsx
304
+ import type { Metadata } from "next";
305
+ import { createBetterCMS, buildMetadata, resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/next";
306
+
307
+ const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });
308
+
309
+ // Your project-wide fallbacks (optional). Per-page meta always wins.
310
+ const siteDefaults: SiteSeoDefaults = {
311
+ metaDescription: "Selected work, experience, and contact information.",
312
+ ogImage: "https://example.com/og-default.png",
313
+ twitterHandle: "@acme",
314
+ };
315
+
316
+ export async function generateMetadata(
317
+ { params }: { params: { slug: string } },
318
+ ): Promise<Metadata> {
319
+ const page = await cms.getPage(params.slug);
320
+ if (!page) return {};
321
+ return buildMetadata(page, siteDefaults);
322
+ }
323
+ ```
324
+
325
+ `buildMetadata` covers `<title>`, description, canonical, Open Graph, and Twitter.
326
+ JSON-LD isn't part of Next's `Metadata`, so emit it from the page component using
327
+ `resolveSeo(...).jsonLd`:
328
+
329
+ ```tsx
330
+ export default async function Page({ params }: { params: { slug: string } }) {
331
+ const page = await cms.getPage(params.slug);
332
+ if (!page) return null;
333
+ const { jsonLd } = resolveSeo(page, siteDefaults);
334
+ return (
335
+ <>
336
+ {jsonLd.map((node, i) => (
337
+ <script
338
+ key={i}
339
+ type="application/ld+json"
340
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
341
+ />
342
+ ))}
343
+ {/* …render page.blocks… */}
344
+ </>
345
+ );
346
+ }
347
+ ```
348
+
349
+ ### Keeping SEO fresh on publish
350
+
351
+ Reads are ISR-cached (`revalidate`, default 60s), so a SEO edit appears after the window
352
+ elapses. For **instant** refresh on publish, configure the project's **revalidation
353
+ webhook** (Project → Settings) to point at a `createRevalidateRoute` handler, passing the
354
+ webhook secret as `secret`. Tag your reads with the `bcms:{workspace}:…` tags from
355
+ "Caching & revalidation". `` cms.getPage(slug, { tags: [`bcms:acme:page:${slug}`] }) `` is the
356
+ page form. Without the webhook, edits still propagate, but on the ISR interval instead of
357
+ immediately.
358
+
359
+ ## License
360
+
361
+ UNLICENSED — internal BetterCMS package.