@bettercms-ai/next 0.21.1 → 0.23.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,321 @@
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
+ ### 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.
File without changes