@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 +361 -321
- package/dist/bettercms-snapshot.js +0 -0
- package/dist/bettercms-snapshot.js.map +1 -1
- package/dist/blocks.js.map +1 -1
- package/dist/draft-bridge.js.map +1 -1
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +143 -4
- package/dist/index.js +111 -0
- package/dist/index.js.map +1 -1
- package/dist/live.js.map +1 -1
- package/dist/phone-field.js.map +1 -1
- package/dist/search.js.map +1 -1
- package/dist/sections.d.ts +88 -0
- package/dist/sections.js +73 -0
- package/dist/sections.js.map +1 -0
- package/dist/visual-editing.js.map +1 -1
- package/package.json +99 -94
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
|
-
###
|
|
237
|
-
|
|
238
|
-
A
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
the
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
+
### 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 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.
|