@riverbankcms/sdk 0.1.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.
Files changed (157) hide show
  1. package/README.md +1892 -0
  2. package/dist/cli/index.js +327 -0
  3. package/dist/cli/index.js.map +1 -0
  4. package/dist/client/analytics.d.mts +103 -0
  5. package/dist/client/analytics.d.ts +103 -0
  6. package/dist/client/analytics.js +197 -0
  7. package/dist/client/analytics.js.map +1 -0
  8. package/dist/client/analytics.mjs +169 -0
  9. package/dist/client/analytics.mjs.map +1 -0
  10. package/dist/client/bookings.d.mts +89 -0
  11. package/dist/client/bookings.d.ts +89 -0
  12. package/dist/client/bookings.js +34 -0
  13. package/dist/client/bookings.js.map +1 -0
  14. package/dist/client/bookings.mjs +11 -0
  15. package/dist/client/bookings.mjs.map +1 -0
  16. package/dist/client/client.d.mts +195 -0
  17. package/dist/client/client.d.ts +195 -0
  18. package/dist/client/client.js +606 -0
  19. package/dist/client/client.js.map +1 -0
  20. package/dist/client/client.mjs +572 -0
  21. package/dist/client/client.mjs.map +1 -0
  22. package/dist/client/hooks.d.mts +71 -0
  23. package/dist/client/hooks.d.ts +71 -0
  24. package/dist/client/hooks.js +264 -0
  25. package/dist/client/hooks.js.map +1 -0
  26. package/dist/client/hooks.mjs +235 -0
  27. package/dist/client/hooks.mjs.map +1 -0
  28. package/dist/client/rendering/client.d.mts +1 -0
  29. package/dist/client/rendering/client.d.ts +1 -0
  30. package/dist/client/rendering/client.js +33 -0
  31. package/dist/client/rendering/client.js.map +1 -0
  32. package/dist/client/rendering/client.mjs +8 -0
  33. package/dist/client/rendering/client.mjs.map +1 -0
  34. package/dist/client/usePage-BvKAa3Zw.d.mts +366 -0
  35. package/dist/client/usePage-BvKAa3Zw.d.ts +366 -0
  36. package/dist/server/chunk-2RW5HAQQ.mjs +86 -0
  37. package/dist/server/chunk-2RW5HAQQ.mjs.map +1 -0
  38. package/dist/server/chunk-3KKZVGH4.mjs +179 -0
  39. package/dist/server/chunk-3KKZVGH4.mjs.map +1 -0
  40. package/dist/server/chunk-4Z3GPTCS.js +179 -0
  41. package/dist/server/chunk-4Z3GPTCS.js.map +1 -0
  42. package/dist/server/chunk-4Z5FBFRL.mjs +211 -0
  43. package/dist/server/chunk-4Z5FBFRL.mjs.map +1 -0
  44. package/dist/server/chunk-ADREPXFU.js +86 -0
  45. package/dist/server/chunk-ADREPXFU.js.map +1 -0
  46. package/dist/server/chunk-F472SMKX.js +140 -0
  47. package/dist/server/chunk-F472SMKX.js.map +1 -0
  48. package/dist/server/chunk-GWBMJPLH.mjs +57 -0
  49. package/dist/server/chunk-GWBMJPLH.mjs.map +1 -0
  50. package/dist/server/chunk-JB4LIEFS.js +85 -0
  51. package/dist/server/chunk-JB4LIEFS.js.map +1 -0
  52. package/dist/server/chunk-PEAXKTDU.mjs +140 -0
  53. package/dist/server/chunk-PEAXKTDU.mjs.map +1 -0
  54. package/dist/server/chunk-QQ6U4QX6.js +120 -0
  55. package/dist/server/chunk-QQ6U4QX6.js.map +1 -0
  56. package/dist/server/chunk-R5YGLRUG.mjs +122 -0
  57. package/dist/server/chunk-R5YGLRUG.mjs.map +1 -0
  58. package/dist/server/chunk-SW7LE4M3.js +211 -0
  59. package/dist/server/chunk-SW7LE4M3.js.map +1 -0
  60. package/dist/server/chunk-W3K7LVPS.mjs +120 -0
  61. package/dist/server/chunk-W3K7LVPS.mjs.map +1 -0
  62. package/dist/server/chunk-WKG57P2H.mjs +85 -0
  63. package/dist/server/chunk-WKG57P2H.mjs.map +1 -0
  64. package/dist/server/chunk-YHEZMVTS.js +122 -0
  65. package/dist/server/chunk-YHEZMVTS.js.map +1 -0
  66. package/dist/server/chunk-YXDDFG3N.js +57 -0
  67. package/dist/server/chunk-YXDDFG3N.js.map +1 -0
  68. package/dist/server/components.d.mts +49 -0
  69. package/dist/server/components.d.ts +49 -0
  70. package/dist/server/components.js +22 -0
  71. package/dist/server/components.js.map +1 -0
  72. package/dist/server/components.mjs +22 -0
  73. package/dist/server/components.mjs.map +1 -0
  74. package/dist/server/config-validation.d.mts +300 -0
  75. package/dist/server/config-validation.d.ts +300 -0
  76. package/dist/server/config-validation.js +50 -0
  77. package/dist/server/config-validation.js.map +1 -0
  78. package/dist/server/config-validation.mjs +50 -0
  79. package/dist/server/config-validation.mjs.map +1 -0
  80. package/dist/server/config.d.mts +38 -0
  81. package/dist/server/config.d.ts +38 -0
  82. package/dist/server/config.js +44 -0
  83. package/dist/server/config.js.map +1 -0
  84. package/dist/server/config.mjs +44 -0
  85. package/dist/server/config.mjs.map +1 -0
  86. package/dist/server/data.d.mts +108 -0
  87. package/dist/server/data.d.ts +108 -0
  88. package/dist/server/data.js +15 -0
  89. package/dist/server/data.js.map +1 -0
  90. package/dist/server/data.mjs +15 -0
  91. package/dist/server/data.mjs.map +1 -0
  92. package/dist/server/index-B0yI_V6Z.d.mts +18 -0
  93. package/dist/server/index-C6M0Wfjq.d.ts +18 -0
  94. package/dist/server/index.d.mts +5 -0
  95. package/dist/server/index.d.ts +5 -0
  96. package/dist/server/index.js +12 -0
  97. package/dist/server/index.js.map +1 -0
  98. package/dist/server/index.mjs +12 -0
  99. package/dist/server/index.mjs.map +1 -0
  100. package/dist/server/loadContent-CJcbYF3J.d.ts +152 -0
  101. package/dist/server/loadContent-zhlL4YSE.d.mts +152 -0
  102. package/dist/server/loadPage-BYmVMk0V.d.ts +216 -0
  103. package/dist/server/loadPage-CCf15nt8.d.mts +216 -0
  104. package/dist/server/loadPage-DVH3DW6E.js +9 -0
  105. package/dist/server/loadPage-DVH3DW6E.js.map +1 -0
  106. package/dist/server/loadPage-PHQZ6XQZ.mjs +9 -0
  107. package/dist/server/loadPage-PHQZ6XQZ.mjs.map +1 -0
  108. package/dist/server/metadata.d.mts +135 -0
  109. package/dist/server/metadata.d.ts +135 -0
  110. package/dist/server/metadata.js +68 -0
  111. package/dist/server/metadata.js.map +1 -0
  112. package/dist/server/metadata.mjs +68 -0
  113. package/dist/server/metadata.mjs.map +1 -0
  114. package/dist/server/rendering/server.d.mts +83 -0
  115. package/dist/server/rendering/server.d.ts +83 -0
  116. package/dist/server/rendering/server.js +14 -0
  117. package/dist/server/rendering/server.js.map +1 -0
  118. package/dist/server/rendering/server.mjs +14 -0
  119. package/dist/server/rendering/server.mjs.map +1 -0
  120. package/dist/server/rendering.d.mts +12 -0
  121. package/dist/server/rendering.d.ts +12 -0
  122. package/dist/server/rendering.js +40 -0
  123. package/dist/server/rendering.js.map +1 -0
  124. package/dist/server/rendering.mjs +40 -0
  125. package/dist/server/rendering.mjs.map +1 -0
  126. package/dist/server/routing.d.mts +115 -0
  127. package/dist/server/routing.d.ts +115 -0
  128. package/dist/server/routing.js +57 -0
  129. package/dist/server/routing.js.map +1 -0
  130. package/dist/server/routing.mjs +57 -0
  131. package/dist/server/routing.mjs.map +1 -0
  132. package/dist/server/server.d.mts +9 -0
  133. package/dist/server/server.d.ts +9 -0
  134. package/dist/server/server.js +21 -0
  135. package/dist/server/server.js.map +1 -0
  136. package/dist/server/server.mjs +21 -0
  137. package/dist/server/server.mjs.map +1 -0
  138. package/dist/server/theme-bridge.d.mts +232 -0
  139. package/dist/server/theme-bridge.d.ts +232 -0
  140. package/dist/server/theme-bridge.js +231 -0
  141. package/dist/server/theme-bridge.js.map +1 -0
  142. package/dist/server/theme-bridge.mjs +231 -0
  143. package/dist/server/theme-bridge.mjs.map +1 -0
  144. package/dist/server/theme.d.mts +40 -0
  145. package/dist/server/theme.d.ts +40 -0
  146. package/dist/server/theme.js +17 -0
  147. package/dist/server/theme.js.map +1 -0
  148. package/dist/server/theme.mjs +17 -0
  149. package/dist/server/theme.mjs.map +1 -0
  150. package/dist/server/types-BCeqWtI2.d.mts +333 -0
  151. package/dist/server/types-BCeqWtI2.d.ts +333 -0
  152. package/dist/server/types-Bbo01M7P.d.mts +76 -0
  153. package/dist/server/types-Bbo01M7P.d.ts +76 -0
  154. package/dist/server/types-C6gmRHLe.d.mts +150 -0
  155. package/dist/server/types-C6gmRHLe.d.ts +150 -0
  156. package/package.json +147 -0
  157. package/src/styles/index.css +10 -0
package/README.md ADDED
@@ -0,0 +1,1892 @@
1
+ # @riverbankcms/sdk
2
+
3
+ A lightweight TypeScript SDK for consuming Riverbank CMS content in React Server Components, Next.js, and client-side React applications.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Server-side rendering** with `loadPage()` helper
8
+ - ✅ **Unified content fetching** with `loadContent()` for pages and entries
9
+ - ✅ **Client-side data fetching** with `usePage()` and `useContent()` hooks
10
+ - ✅ **Automatic data prefetching** for blocks with loaders
11
+ - ✅ **Custom block data loaders** - Config-based (CMS endpoints) and code-based (external APIs)
12
+ - ✅ **Type-safe API client** with caching
13
+ - ✅ **React Server Components** compatible
14
+ - ✅ **Custom blocks** - Define site-specific blocks with full CMS editing support
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @riverbankcms/sdk
20
+ # or
21
+ pnpm add @riverbankcms/sdk
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ > **Important:** The `baseUrl` parameter must be the complete API URL including the `/api` path. For example: `https://dashboard.example.com/api`
27
+
28
+ ### Server-Side Rendering (Recommended)
29
+
30
+ Use in Next.js App Router Server Components or getServerSideProps:
31
+
32
+ ```tsx
33
+ import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
34
+
35
+ // Note: baseUrl must include the /api path
36
+ const client = createRiverbankClient({
37
+ apiKey: process.env.RIVERBANK_API_KEY!,
38
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
39
+ });
40
+
41
+ export default async function HomePage() {
42
+ // Fetch all page data (site, page, block data) in parallel
43
+ const pageData = await loadPage({
44
+ client,
45
+ siteId: 'your-site-id',
46
+ path: '/',
47
+ });
48
+
49
+ return <Page {...pageData} />;
50
+ }
51
+ ```
52
+
53
+ ### Client-Side Rendering
54
+
55
+ For client-only React apps or Client Components:
56
+
57
+ ```tsx
58
+ "use client";
59
+
60
+ import { createRiverbankClient } from '@riverbankcms/sdk';
61
+ import { usePage, Page } from '@riverbankcms/sdk/client';
62
+
63
+ const client = createRiverbankClient({
64
+ apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
65
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
66
+ });
67
+
68
+ export function DynamicPage({ path }: { path: string }) {
69
+ const pageData = usePage({ client, siteId: 'your-site-id', path });
70
+
71
+ if (pageData.loading) return <div>Loading...</div>;
72
+ if (pageData.error) return <div>Error: {pageData.error.message}</div>;
73
+
74
+ return <Page {...pageData} />;
75
+ }
76
+ ```
77
+
78
+ ## API Reference
79
+
80
+ ### Configuration
81
+
82
+ ```ts
83
+ const client = createRiverbankClient({
84
+ apiKey: string; // Required: Builder API key
85
+ baseUrl: string; // Required: Full API URL (e.g., 'https://dashboard.example.com/api')
86
+ cache?: {
87
+ enabled?: boolean; // Default: true
88
+ ttl?: number; // Default: 300 (seconds)
89
+ maxSize?: number; // Default: 100
90
+ };
91
+ });
92
+ ```
93
+
94
+ ### Server-Side API
95
+
96
+ ```tsx
97
+ import { loadPage, Page } from '@riverbankcms/sdk';
98
+
99
+ // Fetch all page data
100
+ const pageData = await loadPage({
101
+ client,
102
+ siteId: string,
103
+ path: string,
104
+ preview?: boolean, // Default: false - set true for draft content
105
+ pageId?: string, // Optional: explicit page ID
106
+ dataLoaderOverrides?: { // Optional: code-based data loaders for custom blocks
107
+ 'custom.block-id': {
108
+ loaderKey: async (ctx) => fetchData(ctx.content.fieldValue),
109
+ },
110
+ },
111
+ });
112
+
113
+ // Render the page
114
+ <Page {...pageData} />
115
+ ```
116
+
117
+ ### Preview Mode (Draft Content)
118
+
119
+ The SDK supports fetching draft/unpublished content by passing `preview: true`. This requires an API key with site access.
120
+
121
+ **Server-Side Preview:**
122
+
123
+ ```tsx
124
+ // app/preview/[[...slug]]/page.tsx
125
+ import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
126
+
127
+ const client = createRiverbankClient({
128
+ apiKey: process.env.RIVERBANK_API_KEY!,
129
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
130
+ });
131
+
132
+ export default async function PreviewPage({ params, searchParams }) {
133
+ const pageData = await loadPage({
134
+ client,
135
+ siteId: searchParams.siteId,
136
+ path: `/${params.slug?.join('/') || ''}`,
137
+ preview: true, // 🔑 Fetch draft content
138
+ });
139
+
140
+ return (
141
+ <div>
142
+ <div className="preview-banner">Preview Mode - Draft Content</div>
143
+ <Page {...pageData} />
144
+ </div>
145
+ );
146
+ }
147
+ ```
148
+
149
+ **Client-Side Preview:**
150
+
151
+ ```tsx
152
+ "use client";
153
+
154
+ import { createRiverbankClient } from '@riverbankcms/sdk';
155
+ import { usePage, Page } from '@riverbankcms/sdk/client';
156
+
157
+ const client = createRiverbankClient({
158
+ apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
159
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
160
+ });
161
+
162
+ export function PreviewPage({ siteId, path }: { siteId: string; path: string }) {
163
+ const pageData = usePage({
164
+ client,
165
+ siteId,
166
+ path,
167
+ preview: true, // 🔑 Fetch draft content
168
+ });
169
+
170
+ if (pageData.loading) return <div>Loading preview...</div>;
171
+ if (pageData.error) return <div>Error: {pageData.error.message}</div>;
172
+
173
+ return <Page {...pageData} />;
174
+ }
175
+ ```
176
+
177
+ **How Preview Works:**
178
+
179
+ - ✅ Uses your existing API key (no separate preview tokens needed)
180
+ - ✅ Returns draft content for blocks (via `draftContent` field)
181
+ - ✅ Shows unpublished pages that are in draft state
182
+ - ✅ Published and preview content are cached separately
183
+ - ✅ Requires API key to be scoped to the site
184
+
185
+ **Use Cases:**
186
+
187
+ - Preview pages before publishing
188
+ - Share draft content with stakeholders
189
+ - Test content changes in staging environment
190
+ - Content editing workflows
191
+
192
+ ### Client-Side API
193
+
194
+ ```tsx
195
+ import { usePage, Page } from '@riverbankcms/sdk/client';
196
+
197
+ const pageData = usePage({
198
+ client,
199
+ siteId: string,
200
+ path: string,
201
+ preview?: boolean, // Default: false - set true for draft content
202
+ pageId?: string,
203
+ });
204
+
205
+ // pageData is a discriminated union:
206
+ // { loading: true, error: null, ... } |
207
+ // { loading: false, error: Error, ... } |
208
+ // { loading: false, error: null, page, theme, ... }
209
+ ```
210
+
211
+ ## Unified Content Loading (Pages + Entries)
212
+
213
+ When a path might resolve to either a page or a content entry (blog post, product, etc.), use `loadContent()` or `useContent()` instead of the page-specific functions.
214
+
215
+ ### Server-Side: loadContent
216
+
217
+ ```tsx
218
+ import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';
219
+
220
+ export default async function DynamicRoute({ params }) {
221
+ const path = `/${params.slug?.join('/') || ''}`;
222
+
223
+ const content = await loadContent({
224
+ client,
225
+ siteId: 'your-site-id',
226
+ path,
227
+ preview: false, // Set true for draft content
228
+ });
229
+
230
+ // Pages get rendered with the Page component
231
+ if (isPageContent(content)) {
232
+ return <Page {...content} />;
233
+ }
234
+
235
+ // Entries get rendered with custom UI
236
+ return (
237
+ <article>
238
+ <h1>{content.entry.title}</h1>
239
+ <div>{JSON.stringify(content.entry.content)}</div>
240
+ </article>
241
+ );
242
+ }
243
+ ```
244
+
245
+ ### Client-Side: useContent
246
+
247
+ ```tsx
248
+ "use client";
249
+
250
+ import { useContent, isPageContentResult, Page } from '@riverbankcms/sdk/client';
251
+
252
+ export function DynamicContent({ path }: { path: string }) {
253
+ const content = useContent({
254
+ client,
255
+ siteId: 'your-site-id',
256
+ path,
257
+ preview: false,
258
+ });
259
+
260
+ if (content.loading) return <div>Loading...</div>;
261
+ if (content.error) return <div>Error: {content.error.message}</div>;
262
+
263
+ if (isPageContentResult(content)) {
264
+ return <Page page={content.page} theme={content.theme} siteId={content.siteId} resolvedData={content.resolvedData} />;
265
+ }
266
+
267
+ // Render entry with custom UI
268
+ return (
269
+ <article>
270
+ <h1>{content.entry.title}</h1>
271
+ <p>Type: {content.entry.type}</p>
272
+ <div>{JSON.stringify(content.entry.content)}</div>
273
+ </article>
274
+ );
275
+ }
276
+ ```
277
+
278
+ ### Entry-Specific Rendering
279
+
280
+ Route entries to different components based on their content type:
281
+
282
+ ```tsx
283
+ import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';
284
+
285
+ export default async function DynamicRoute({ params }) {
286
+ const content = await loadContent({ client, siteId, path: `/${params.slug?.join('/') || ''}` });
287
+
288
+ if (isPageContent(content)) {
289
+ return <Page {...content} />;
290
+ }
291
+
292
+ // Route to type-specific components
293
+ switch (content.entry.type) {
294
+ case 'blog-post':
295
+ return <BlogPost entry={content.entry} theme={content.theme} />;
296
+ case 'product':
297
+ return <ProductPage entry={content.entry} theme={content.theme} />;
298
+ case 'event':
299
+ return <EventPage entry={content.entry} theme={content.theme} />;
300
+ default:
301
+ return <GenericEntry entry={content.entry} />;
302
+ }
303
+ }
304
+
305
+ function BlogPost({ entry, theme }) {
306
+ const { title, content, metaDescription } = entry;
307
+ return (
308
+ <article>
309
+ <h1>{title}</h1>
310
+ <p className="lead">{metaDescription}</p>
311
+ <div className="prose">{content.body}</div>
312
+ <p>By {content.author} on {new Date(entry.createdAt).toLocaleDateString()}</p>
313
+ </article>
314
+ );
315
+ }
316
+ ```
317
+
318
+ ### ContentEntryData Type
319
+
320
+ When working with entries, you receive raw content data:
321
+
322
+ ```ts
323
+ type ContentEntryData = {
324
+ id: string;
325
+ type: string | null; // Content type key (e.g., 'blog-post', 'product')
326
+ title: string;
327
+ slug: string | null;
328
+ path: string | null;
329
+ status: string;
330
+ publishAt: string | null;
331
+ content: Record<string, unknown>; // Raw content fields
332
+ metaTitle: string | null;
333
+ metaDescription: string | null;
334
+ createdAt: string;
335
+ updatedAt: string;
336
+ };
337
+ ```
338
+
339
+ ### When to Use Which Function
340
+
341
+ | Function | Use Case |
342
+ |----------|----------|
343
+ | `loadPage()` / `usePage()` | Pages only - throws error on entries |
344
+ | `loadContent()` / `useContent()` | Both pages and entries - discriminated union |
345
+
346
+ ## Fetching Multiple Entries
347
+
348
+ Use `client.getEntries()` to fetch lists of content entries for blog listing pages, product catalogs, etc.
349
+
350
+ ### Basic Usage
351
+
352
+ ```tsx
353
+ import { createRiverbankClient } from '@riverbankcms/sdk';
354
+
355
+ const client = createRiverbankClient({
356
+ apiKey: process.env.RIVERBANK_API_KEY!,
357
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
358
+ });
359
+
360
+ // Fetch all published blog posts
361
+ const { entries } = await client.getEntries({
362
+ siteId: 'your-site-id',
363
+ contentType: 'blog-post',
364
+ });
365
+ ```
366
+
367
+ ### Pagination and Sorting
368
+
369
+ ```tsx
370
+ // Fetch latest 10 posts (newest first)
371
+ const { entries: latestPosts } = await client.getEntries({
372
+ siteId: 'your-site-id',
373
+ contentType: 'blog-post',
374
+ limit: 10,
375
+ order: 'newest',
376
+ });
377
+
378
+ // Fetch oldest posts first (for archives)
379
+ const { entries: archivedPosts } = await client.getEntries({
380
+ siteId: 'your-site-id',
381
+ contentType: 'blog-post',
382
+ order: 'oldest',
383
+ });
384
+
385
+ // Include draft entries for preview mode
386
+ const { entries: allPosts } = await client.getEntries({
387
+ siteId: 'your-site-id',
388
+ contentType: 'blog-post',
389
+ preview: true, // Includes unpublished drafts
390
+ });
391
+ ```
392
+
393
+ ### Parameters
394
+
395
+ | Parameter | Type | Required | Description |
396
+ |-----------|------|----------|-------------|
397
+ | `siteId` | string | Yes | Site ID |
398
+ | `contentType` | string | Yes | Content type key (e.g., 'blog-post') |
399
+ | `limit` | number | No | Maximum entries to return |
400
+ | `order` | 'newest' \| 'oldest' | No | Sort by publish date |
401
+ | `preview` | boolean | No | Include draft entries |
402
+
403
+ ### Real-World Example: Blog Listing Page
404
+
405
+ ```tsx
406
+ // app/blog/page.tsx
407
+ import { createRiverbankClient } from '@riverbankcms/sdk';
408
+ import Link from 'next/link';
409
+
410
+ const client = createRiverbankClient({
411
+ apiKey: process.env.RIVERBANK_API_KEY!,
412
+ baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
413
+ });
414
+
415
+ export default async function BlogPage() {
416
+ const site = await client.getSite({ slug: 'my-site' });
417
+
418
+ const { entries: posts } = await client.getEntries({
419
+ siteId: site.site.id,
420
+ contentType: 'blog-post',
421
+ limit: 10,
422
+ order: 'newest',
423
+ });
424
+
425
+ return (
426
+ <main>
427
+ <h1>Blog</h1>
428
+ <div className="grid gap-6">
429
+ {posts.map((post) => (
430
+ <article key={post.id}>
431
+ <Link href={`/blog/${post.slug}`}>
432
+ <h2>{post.title}</h2>
433
+ </Link>
434
+ <p>{post.metaDescription}</p>
435
+ <time>{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : 'Draft'}</time>
436
+ </article>
437
+ ))}
438
+ </div>
439
+ </main>
440
+ );
441
+ }
442
+ ```
443
+
444
+ ## Import Patterns
445
+
446
+ ⚠️ **Important:** Use the correct import path for your environment:
447
+
448
+ ```tsx
449
+ // ✅ Server Components (Next.js App Router)
450
+ import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
451
+
452
+ // ✅ Client Components (use "use client" directive)
453
+ "use client";
454
+ import { usePage, Page } from '@riverbankcms/sdk/client';
455
+ ```
456
+
457
+ The `/client` subpath exports React hooks that require client-side React features (useState, useEffect). The main entry point only exports server-safe code.
458
+
459
+ ## Layout Component
460
+
461
+ The `Layout` component renders the site header, footer, and wraps your page content. Use this when you want consistent site chrome across all pages.
462
+
463
+ ### Using Layout with Pre-Fetched Site Data
464
+
465
+ When you already have site data (from `loadPage` or `client.getSite`):
466
+
467
+ ```tsx
468
+ import { Layout } from '@riverbankcms/sdk';
469
+
470
+ export default async function MyPage() {
471
+ const siteData = await client.getSite({ slug: 'my-site' });
472
+
473
+ return (
474
+ <Layout siteData={siteData}>
475
+ <main>
476
+ <h1>My Custom Page</h1>
477
+ <p>This content is wrapped with site header and footer.</p>
478
+ </main>
479
+ </Layout>
480
+ );
481
+ }
482
+ ```
483
+
484
+ ### Automatic Site Fetching
485
+
486
+ Layout can fetch site data automatically if you provide a client and identifier:
487
+
488
+ ```tsx
489
+ import { Layout } from '@riverbankcms/sdk';
490
+
491
+ export default async function MyPage() {
492
+ return (
493
+ <Layout
494
+ client={client}
495
+ slug="my-site" // or siteId="..." or domain="example.com"
496
+ >
497
+ <main>
498
+ <h1>My Custom Page</h1>
499
+ </main>
500
+ </Layout>
501
+ );
502
+ }
503
+ ```
504
+
505
+ ### Customizing Header and Footer
506
+
507
+ You can hide the header or footer:
508
+
509
+ ```tsx
510
+ <Layout
511
+ siteData={siteData}
512
+ header={false} // Hide header
513
+ footer={false} // Hide footer
514
+ >
515
+ <main>Landing page without navigation</main>
516
+ </Layout>
517
+ ```
518
+
519
+ ### Choosing Header Variants
520
+
521
+ Override the header variant from your code:
522
+
523
+ ```tsx
524
+ <Layout
525
+ siteData={siteData}
526
+ headerVariant="centered" // Options: classic, centered, transparent, floating, editorial
527
+ >
528
+ <main>Content with centered header</main>
529
+ </Layout>
530
+ ```
531
+
532
+ ### Fully Custom Headers
533
+
534
+ Create your own header while using CMS navigation data:
535
+
536
+ ```tsx
537
+ import { Layout, type HeaderData } from '@riverbankcms/sdk';
538
+
539
+ function CustomHeader({ menu, logo, site }: HeaderData) {
540
+ return (
541
+ <header className="custom-header">
542
+ <a href="/">{logo?.url && <img src={logo.url} alt={site.title} />}</a>
543
+ <nav>
544
+ {menu.items.map(item => (
545
+ <a key={item.id} href={item.url.href}>{item.label}</a>
546
+ ))}
547
+ </nav>
548
+ </header>
549
+ );
550
+ }
551
+
552
+ export default async function MyPage() {
553
+ const siteData = await client.getSite({ slug: 'my-site' });
554
+
555
+ return (
556
+ <Layout
557
+ siteData={siteData}
558
+ header={(data) => <CustomHeader {...data} />}
559
+ >
560
+ <main>Custom header with CMS navigation</main>
561
+ </Layout>
562
+ );
563
+ }
564
+ ```
565
+
566
+ ## Block Component
567
+
568
+ The `Block` component renders individual CMS blocks. Use this when you want to mix CMS content with custom JSX, or render blocks outside of a full page context.
569
+
570
+ ### Basic Block Rendering
571
+
572
+ ```tsx
573
+ import { Block } from '@riverbankcms/sdk';
574
+
575
+ export default async function CustomPage() {
576
+ const siteData = await client.getSite({ slug: 'my-site' });
577
+
578
+ return (
579
+ <div>
580
+ <h1>Custom Header</h1>
581
+
582
+ {/* Render a CMS block inline */}
583
+ <Block
584
+ blockKind="block.hero"
585
+ content={{
586
+ headline: 'Welcome',
587
+ subheadline: 'To our custom page',
588
+ cta: { label: 'Get Started', href: '/signup' }
589
+ }}
590
+ theme={siteData.theme}
591
+ siteId={siteData.site.id}
592
+ />
593
+
594
+ <p>More custom content...</p>
595
+ </div>
596
+ );
597
+ }
598
+ ```
599
+
600
+ ### Block with Data Loading
601
+
602
+ Blocks can automatically load their data (e.g., blog posts, products) if they have data loaders configured:
603
+
604
+ ```tsx
605
+ <Block
606
+ blockKind="block.blog-listing"
607
+ blockId="block-abc123" // Block ID enables data loading
608
+ content={{ maxPosts: 10 }}
609
+ theme={siteData.theme}
610
+ siteId={siteData.site.id}
611
+ pageId={page.id} // Optional: for context-aware loaders
612
+ client={client} // Required for data loading
613
+ />
614
+ ```
615
+
616
+ ### Advanced Block Options
617
+
618
+ ```tsx
619
+ <Block
620
+ blockKind="block.hero"
621
+ blockId="block-xyz"
622
+ content={{ heading: 'Welcome' }}
623
+ theme={theme}
624
+ siteId="site-123"
625
+
626
+ // Optional data loading context
627
+ pageId="page-456"
628
+ previewStage="preview" // or "published"
629
+ client={client}
630
+
631
+ // Show placeholder data for missing loaders
632
+ usePlaceholders={true}
633
+ />
634
+ ```
635
+
636
+ ## Metadata Generation
637
+
638
+ Generate SEO-optimized metadata for Next.js pages from Riverbank CMS data.
639
+
640
+ ### Basic Usage
641
+
642
+ ```tsx
643
+ import { generatePageMetadata } from '@riverbankcms/sdk/metadata';
644
+ import { loadPage } from '@riverbankcms/sdk';
645
+
646
+ export async function generateMetadata({ params }) {
647
+ const pageData = await loadPage({ client, siteId, path: params.slug });
648
+ const siteData = await client.getSite({ id: siteId });
649
+
650
+ return generatePageMetadata({
651
+ page: pageData.page,
652
+ site: siteData.site,
653
+ path: params.slug || '/',
654
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
655
+ });
656
+ }
657
+ ```
658
+
659
+ ### With Custom Overrides
660
+
661
+ ```tsx
662
+ const metadata = generatePageMetadata({
663
+ page: pageData.page,
664
+ site: siteData.site,
665
+ path: '/',
666
+ siteUrl: 'https://example.com',
667
+ overrides: {
668
+ title: 'Custom SEO Title',
669
+ description: 'Custom SEO description with keywords',
670
+ ogImage: 'https://example.com/og-image.jpg',
671
+ canonicalUrl: 'https://example.com/canonical-path',
672
+ },
673
+ googleSiteVerification: 'verification-token',
674
+ });
675
+ ```
676
+
677
+ ### Preview Environment Metadata
678
+
679
+ ```tsx
680
+ import { generatePreviewMetadata } from '@riverbankcms/sdk/metadata';
681
+
682
+ // Adds noindex/nofollow for staging environments
683
+ const metadata = generatePreviewMetadata({
684
+ page: pageData.page,
685
+ site: siteData.site,
686
+ path: '/',
687
+ siteUrl: 'https://preview.example.com',
688
+ });
689
+ ```
690
+
691
+ ## Route Resolution
692
+
693
+ Resolve URL paths to pages, redirects, or 404s for dynamic routing.
694
+
695
+ ### Single Route Resolution
696
+
697
+ ```tsx
698
+ import { resolveRoute } from '@riverbankcms/sdk/routing';
699
+ import { notFound, redirect } from 'next/navigation';
700
+
701
+ export default async function DynamicPage({ params }) {
702
+ const path = `/${params.slug?.join('/') || ''}`;
703
+
704
+ const resolution = await resolveRoute({
705
+ client,
706
+ siteId: 'your-site-id',
707
+ path,
708
+ });
709
+
710
+ if (resolution.type === 'redirect') {
711
+ redirect(resolution.destination);
712
+ }
713
+
714
+ if (resolution.type === 'not-found') {
715
+ notFound();
716
+ }
717
+
718
+ return <Page {...resolution.pageData} />;
719
+ }
720
+ ```
721
+
722
+ ### Batch Route Resolution
723
+
724
+ ```tsx
725
+ import { resolveRoutes } from '@riverbankcms/sdk/routing';
726
+
727
+ // Useful for sitemap generation or validation
728
+ const resolutions = await resolveRoutes({
729
+ client,
730
+ siteId: 'your-site-id',
731
+ paths: ['/', '/about', '/services', '/contact'],
732
+ });
733
+
734
+ resolutions.forEach(({ path, resolution }) => {
735
+ if (resolution.type === 'page') {
736
+ console.log(`${path} → Page: ${resolution.pageData.page.name}`);
737
+ } else if (resolution.type === 'redirect') {
738
+ console.log(`${path} → Redirect to ${resolution.destination}`);
739
+ } else {
740
+ console.log(`${path} → Not found`);
741
+ }
742
+ });
743
+ ```
744
+
745
+ ## Analytics
746
+
747
+ Track page views, CTA clicks, form submissions, and custom events.
748
+
749
+ ### Add Analytics to Layout
750
+
751
+ ```tsx
752
+ import { AnalyticsBootstrap } from '@riverbankcms/sdk/analytics';
753
+
754
+ export default function RootLayout({ children }) {
755
+ return (
756
+ <html>
757
+ <body>
758
+ {children}
759
+
760
+ {/* Auto-tracks page views on navigation */}
761
+ <AnalyticsBootstrap
762
+ siteId="your-site-id"
763
+ siteSlug="your-site-slug"
764
+ endpoint="/api/analytics/collect"
765
+ />
766
+ </body>
767
+ </html>
768
+ );
769
+ }
770
+ ```
771
+
772
+ ### Track Events in Components
773
+
774
+ ```tsx
775
+ 'use client';
776
+
777
+ import { useAnalytics } from '@riverbankcms/sdk/analytics';
778
+
779
+ export function MyComponent() {
780
+ const analytics = useAnalytics({
781
+ siteId: 'your-site-id',
782
+ siteSlug: 'your-site-slug',
783
+ });
784
+
785
+ const handleClick = () => {
786
+ analytics.trackCtaClick({ buttonLabel: 'Sign Up' });
787
+ };
788
+
789
+ const handleSubmit = () => {
790
+ analytics.trackFormSubmit({ formName: 'contact-form' });
791
+ };
792
+
793
+ const handleCustomEvent = () => {
794
+ analytics.trackEvent({
795
+ eventType: 'video_play',
796
+ metadata: { videoId: '123', duration: 120 },
797
+ });
798
+ };
799
+
800
+ return (
801
+ <>
802
+ <button onClick={handleClick}>Sign Up</button>
803
+ <form onSubmit={handleSubmit}>...</form>
804
+ </>
805
+ );
806
+ }
807
+ ```
808
+
809
+ ## Theming
810
+
811
+ Style Builder blocks to match your brand using the Theme Bridge. This provides CSS variables and optional component styles without requiring the full CMS theme system.
812
+
813
+ ### Quick Start
814
+
815
+ Wrap your app with `ThemeBridgeProvider` and define your color tokens:
816
+
817
+ ```tsx
818
+ import { ThemeBridgeProvider } from '@riverbankcms/sdk/theme-bridge';
819
+
820
+ export default function RootLayout({ children }) {
821
+ return (
822
+ <ThemeBridgeProvider
823
+ config={{
824
+ tokens: {
825
+ primary: '#6d28d9',
826
+ secondary: '#4c1d95',
827
+ background: '#ffffff',
828
+ text: '#1e293b',
829
+ },
830
+ }}
831
+ >
832
+ {children}
833
+ </ThemeBridgeProvider>
834
+ );
835
+ }
836
+ ```
837
+
838
+ ### Token System
839
+
840
+ Define color tokens as key-value pairs. Keys become CSS variables (`--color-{key}`):
841
+
842
+ ```tsx
843
+ <ThemeBridgeProvider
844
+ config={{
845
+ tokens: {
846
+ // Brand colors
847
+ primary: '#6d28d9',
848
+ secondary: '#4c1d95',
849
+
850
+ // Backgrounds
851
+ background: '#ffffff',
852
+ surface: '#f8fafc',
853
+
854
+ // Text
855
+ text: '#1e293b',
856
+ mutedText: '#64748b',
857
+
858
+ // UI
859
+ border: '#e2e8f0',
860
+ white: '#ffffff',
861
+
862
+ // Status
863
+ success: '#22c55e',
864
+ warning: '#f59e0b',
865
+ danger: '#ef4444',
866
+ },
867
+ }}
868
+ >
869
+ ```
870
+
871
+ Token values can be:
872
+ - **Hex colors**: `'#6d28d9'` (converted to RGB for Tailwind alpha support)
873
+ - **CSS variable refs**: `'var(--brand-purple)'` (passed through)
874
+ - **RGB values**: `'109 40 217'` (used directly)
875
+
876
+ ### Design Presets
877
+
878
+ Control typography, spacing, corners, and shadows:
879
+
880
+ ```tsx
881
+ <ThemeBridgeProvider
882
+ config={{
883
+ tokens: { /* ... */ },
884
+
885
+ // Typography
886
+ typography: {
887
+ headingFamily: '"Inter", sans-serif',
888
+ bodyFamily: '"Inter", sans-serif',
889
+ headingWeight: 700,
890
+ bodyWeight: 400,
891
+ },
892
+
893
+ // Spacing density
894
+ spacing: 'standard', // 'comfortable' | 'standard' | 'dense'
895
+
896
+ // Corner radius
897
+ corners: 'rounded', // 'square' | 'soft' | 'rounded' | 'pill'
898
+
899
+ // Shadow intensity
900
+ shadows: 'medium', // 'none' | 'low' | 'medium' | 'high'
901
+ }}
902
+ >
903
+ ```
904
+
905
+ ### Component CSS (Opt-in)
906
+
907
+ By default, only CSS variables are generated. Enable component CSS for buttons, cards, and inputs:
908
+
909
+ ```tsx
910
+ <ThemeBridgeProvider
911
+ config={{
912
+ tokens: {
913
+ primary: '#6d28d9',
914
+ secondary: '#4c1d95',
915
+ white: '#ffffff',
916
+ surface: '#f8fafc',
917
+ text: '#1e293b',
918
+ border: '#e2e8f0',
919
+ },
920
+ corners: 'rounded',
921
+ shadows: 'medium',
922
+ components: {
923
+ buttons: true, // Generates .button-primary, .button-secondary, etc.
924
+ cards: true, // Generates .card-default, .card-elevated, etc.
925
+ inputs: true, // Generates .form-input, .form-label, etc.
926
+ },
927
+ }}
928
+ >
929
+ ```
930
+
931
+ ### Custom Component Variants
932
+
933
+ Specify which variants to generate:
934
+
935
+ ```tsx
936
+ components: {
937
+ buttons: {
938
+ variants: ['primary', 'secondary'], // Only these variants
939
+ },
940
+ cards: {
941
+ variants: ['default', 'elevated'],
942
+ },
943
+ }
944
+ ```
945
+
946
+ ### CSS Overrides
947
+
948
+ Add custom CSS rules scoped to theme components:
949
+
950
+ ```tsx
951
+ <ThemeBridgeProvider
952
+ config={{
953
+ tokens: { /* ... */ },
954
+ components: { buttons: true },
955
+ overrides: {
956
+ '.button-primary': 'border-radius: 9999px; font-weight: 700;',
957
+ '.button-primary:hover': 'transform: translateY(-2px);',
958
+ },
959
+ }}
960
+ >
961
+ ```
962
+
963
+ ### Pass-Through to Design Systems
964
+
965
+ Reference existing CSS variables from your design system:
966
+
967
+ ```tsx
968
+ <ThemeBridgeProvider
969
+ config={{
970
+ tokens: {
971
+ primary: 'var(--brand-purple)',
972
+ secondary: 'var(--brand-navy)',
973
+ background: 'var(--ds-bg)',
974
+ text: 'var(--ds-text)',
975
+ },
976
+ components: { buttons: true },
977
+ }}
978
+ >
979
+ ```
980
+
981
+ ### Using Generated CSS Directly
982
+
983
+ For advanced use cases, generate CSS without the provider:
984
+
985
+ ```tsx
986
+ import { generateThemeBridgeCss } from '@riverbankcms/sdk/theme-bridge';
987
+
988
+ const { css, cssVars } = generateThemeBridgeCss({
989
+ tokens: {
990
+ primary: '#6d28d9',
991
+ background: '#ffffff',
992
+ },
993
+ components: { buttons: true },
994
+ });
995
+
996
+ // css: Full CSS string for injection
997
+ // cssVars: Object of CSS variable name-value pairs
998
+ ```
999
+
1000
+ ### Available Exports
1001
+
1002
+ ```tsx
1003
+ import {
1004
+ // Provider component
1005
+ ThemeBridgeProvider,
1006
+ useThemeBridgeCss,
1007
+
1008
+ // CSS generator
1009
+ generateThemeBridgeCss,
1010
+
1011
+ // Types
1012
+ type ThemeBridgeConfig,
1013
+ type ThemeBridgeTypography,
1014
+ type ThemeBridgeSpacing,
1015
+ type ThemeBridgeCorners,
1016
+ type ThemeBridgeShadows,
1017
+ type ThemeBridgeComponents,
1018
+ type ThemeBridgeOutput,
1019
+ } from '@riverbankcms/sdk/theme-bridge';
1020
+ ```
1021
+
1022
+ ## Block Field Options
1023
+
1024
+ Customize field options for specific blocks directly in your SDK configuration. This allows SDK sites to define site-specific choices for select fields in system blocks.
1025
+
1026
+ ### Basic Configuration
1027
+
1028
+ Add `blockFieldOptions` to your `riverbank.config.ts`:
1029
+
1030
+ ```typescript
1031
+ import { defineConfig } from '@riverbankcms/sdk/config';
1032
+
1033
+ export default defineConfig({
1034
+ siteId: 'your-site-id',
1035
+ blockFieldOptions: {
1036
+ 'block.embed': {
1037
+ layout: {
1038
+ options: [
1039
+ { value: 'showcase', label: 'Showcase Grid' },
1040
+ { value: 'list', label: 'Simple List' },
1041
+ { value: 'featured', label: 'Featured Hero' },
1042
+ ]
1043
+ }
1044
+ }
1045
+ }
1046
+ });
1047
+ ```
1048
+
1049
+ ### How It Works
1050
+
1051
+ 1. **Block ID format**: Use `block.*` for system blocks or `custom.*` for custom blocks
1052
+ 2. **Field ID**: The field within the block whose options you want to override
1053
+ 3. **Options**: Array of `{ value, label }` objects that replace the default options
1054
+
1055
+ When a block field uses the `sdkSelect` widget (like the embed block's `layout` field), it will:
1056
+ - Use SDK-provided options when available in `blockFieldOptions`
1057
+ - Fall back to the field's default options if no SDK options are configured
1058
+
1059
+ ### Types
1060
+
1061
+ ```typescript
1062
+ import type {
1063
+ FieldSelectOption,
1064
+ BlockFieldConfig,
1065
+ BlockFieldOptionsMap,
1066
+ } from '@riverbankcms/sdk/config';
1067
+
1068
+ // A single select option
1069
+ type FieldSelectOption = {
1070
+ value: string; // Value stored when selected
1071
+ label: string; // Display text in dropdown
1072
+ };
1073
+
1074
+ // Configuration for a field
1075
+ type BlockFieldConfig = {
1076
+ options?: FieldSelectOption[]; // Override select options
1077
+ };
1078
+
1079
+ // Map of block IDs to field configurations
1080
+ type BlockFieldOptionsMap = Record<string, Record<string, BlockFieldConfig>>;
1081
+ ```
1082
+
1083
+ ### Current Use Cases
1084
+
1085
+ **Embed Block Layout**: The `block.embed` block uses `sdkSelect` for its `layout` field, allowing SDK sites to define custom layout options that match their site-specific renderers:
1086
+
1087
+ ```typescript
1088
+ export default defineConfig({
1089
+ siteId: 'your-site-id',
1090
+ blockFieldOptions: {
1091
+ 'block.embed': {
1092
+ layout: {
1093
+ options: [
1094
+ { value: 'blog-grid', label: 'Blog Grid' },
1095
+ { value: 'team-cards', label: 'Team Cards' },
1096
+ { value: 'portfolio', label: 'Portfolio Gallery' },
1097
+ ]
1098
+ }
1099
+ }
1100
+ }
1101
+ });
1102
+ ```
1103
+
1104
+ Then in your block override, handle each layout:
1105
+
1106
+ ```tsx
1107
+ function EmbedRenderer({ content, data }) {
1108
+ switch (content.layout) {
1109
+ case 'blog-grid':
1110
+ return <BlogGrid entries={data.entries} />;
1111
+ case 'team-cards':
1112
+ return <TeamCards entries={data.entries} />;
1113
+ case 'portfolio':
1114
+ return <PortfolioGallery entries={data.entries} />;
1115
+ default:
1116
+ return <DefaultList entries={data.entries} />;
1117
+ }
1118
+ }
1119
+ ```
1120
+
1121
+ ### Validation Rules
1122
+
1123
+ - Block IDs must match pattern: `block.*` or `custom.*` (lowercase letters, numbers, hyphens)
1124
+ - Field IDs must be non-empty strings
1125
+ - Each option must have both `value` and `label` (non-empty strings)
1126
+ - At least one option is required when `options` is specified
1127
+
1128
+ ## Block Field Extensions
1129
+
1130
+ Add custom fields to built-in block types without modifying the core CMS. This is different from `blockFieldOptions` (which overrides options for existing fields) – `blockFieldExtensions` lets you add entirely new fields.
1131
+
1132
+ ### Basic Configuration
1133
+
1134
+ Add `blockFieldExtensions` to your `riverbank.config.ts`:
1135
+
1136
+ ```typescript
1137
+ import { defineConfig } from '@riverbankcms/sdk/config';
1138
+
1139
+ export default defineConfig({
1140
+ siteId: 'your-site-id',
1141
+ blockFieldExtensions: {
1142
+ 'block.body-text': {
1143
+ fields: [
1144
+ {
1145
+ id: 'layout',
1146
+ type: 'select',
1147
+ label: 'Layout',
1148
+ defaultValue: 'default',
1149
+ required: false,
1150
+ multiple: false,
1151
+ options: [
1152
+ { value: 'default', label: 'Default' },
1153
+ { value: 'wide', label: 'Wide' },
1154
+ { value: 'narrow', label: 'Narrow' },
1155
+ { value: 'pullquote', label: 'Pull Quote' },
1156
+ ],
1157
+ },
1158
+ ],
1159
+ },
1160
+ 'block.hero': {
1161
+ fields: [
1162
+ {
1163
+ id: 'videoBackground',
1164
+ type: 'media',
1165
+ label: 'Video Background',
1166
+ description: 'Optional video to play behind the hero',
1167
+ mediaKinds: ['video'],
1168
+ required: false,
1169
+ },
1170
+ {
1171
+ id: 'overlayOpacity',
1172
+ type: 'number',
1173
+ label: 'Overlay Opacity',
1174
+ description: 'Darkness of the overlay (0-100)',
1175
+ defaultValue: 50,
1176
+ required: false,
1177
+ },
1178
+ ],
1179
+ },
1180
+ },
1181
+ });
1182
+ ```
1183
+
1184
+ ### How It Works
1185
+
1186
+ 1. **Extended fields appear at the end** of the block's editing form in the CMS
1187
+ 2. **Field values are stored** alongside normal block content
1188
+ 3. **Values are accessible** in your `blockOverrides` via the `content` prop
1189
+ 4. **All field types supported**: text, select, media, repeater, group, etc.
1190
+
1191
+ ### Accessing Extended Fields in blockOverrides
1192
+
1193
+ ```tsx
1194
+ import { loadPage, Page } from '@riverbankcms/sdk';
1195
+
1196
+ export default async function CMSPage({ params }) {
1197
+ const pageData = await loadPage({ client, siteId, path: '/' });
1198
+
1199
+ return (
1200
+ <Page
1201
+ {...pageData}
1202
+ blockOverrides={{
1203
+ bodyText: ({ content }) => {
1204
+ // Extended field values are available on content
1205
+ const layout = content.layout ?? 'default';
1206
+
1207
+ const layoutClass = {
1208
+ default: 'max-w-prose mx-auto',
1209
+ wide: 'max-w-4xl mx-auto',
1210
+ narrow: 'max-w-md mx-auto',
1211
+ pullquote: 'max-w-lg mx-auto text-xl italic border-l-4 pl-6',
1212
+ }[layout];
1213
+
1214
+ return (
1215
+ <div className={layoutClass}>
1216
+ <RichText content={content.body} />
1217
+ </div>
1218
+ );
1219
+ },
1220
+ hero: ({ content }) => {
1221
+ const { videoBackground, overlayOpacity = 50 } = content;
1222
+
1223
+ return (
1224
+ <div className="relative">
1225
+ {videoBackground && (
1226
+ <video
1227
+ src={videoBackground.url}
1228
+ className="absolute inset-0 w-full h-full object-cover"
1229
+ autoPlay
1230
+ muted
1231
+ loop
1232
+ />
1233
+ )}
1234
+ <div
1235
+ className="absolute inset-0 bg-black"
1236
+ style={{ opacity: overlayOpacity / 100 }}
1237
+ />
1238
+ <div className="relative z-10">
1239
+ <h1>{content.headline}</h1>
1240
+ </div>
1241
+ </div>
1242
+ );
1243
+ },
1244
+ }}
1245
+ />
1246
+ );
1247
+ }
1248
+ ```
1249
+
1250
+ ### Types
1251
+
1252
+ ```typescript
1253
+ import type {
1254
+ BlockFieldExtension,
1255
+ BlockFieldExtensionsMap,
1256
+ FieldDefinition,
1257
+ } from '@riverbankcms/sdk/config';
1258
+
1259
+ // Configuration for extending a single block
1260
+ type BlockFieldExtension = {
1261
+ fields: FieldDefinition[]; // Same field format as block manifests
1262
+ };
1263
+
1264
+ // Map of block IDs to their extensions
1265
+ type BlockFieldExtensionsMap = Record<string, BlockFieldExtension>;
1266
+ ```
1267
+
1268
+ ### Validation Rules
1269
+
1270
+ - **Block IDs must be system blocks**: Only `block.*` format (e.g., `block.body-text`, `block.hero`). Custom blocks (`custom.*`) should define their fields directly in `customBlocks`.
1271
+ - **Field IDs must be unique**: Extended field IDs cannot conflict with existing fields in the block. The CLI will error if you try to add a field with an ID that already exists.
1272
+ - **Required fields need defaultValue**: If `required: true`, you must provide a `defaultValue`. This ensures existing blocks (created before the extension) can still be edited.
1273
+ - **At least one field required**: Each block extension must have at least one field.
1274
+
1275
+ ### Use Cases
1276
+
1277
+ **Layout variations for text blocks:**
1278
+ ```typescript
1279
+ 'block.body-text': {
1280
+ fields: [
1281
+ {
1282
+ id: 'layout',
1283
+ type: 'select',
1284
+ label: 'Layout',
1285
+ defaultValue: 'default',
1286
+ required: false,
1287
+ multiple: false,
1288
+ options: [
1289
+ { value: 'default', label: 'Default' },
1290
+ { value: 'wide', label: 'Wide' },
1291
+ { value: 'sidebar', label: 'With Sidebar' },
1292
+ ],
1293
+ },
1294
+ ],
1295
+ }
1296
+ ```
1297
+
1298
+ **Custom styling options:**
1299
+ ```typescript
1300
+ 'block.hero': {
1301
+ fields: [
1302
+ {
1303
+ id: 'textAlignment',
1304
+ type: 'select',
1305
+ label: 'Text Alignment',
1306
+ defaultValue: 'center',
1307
+ required: false,
1308
+ multiple: false,
1309
+ options: [
1310
+ { value: 'left', label: 'Left' },
1311
+ { value: 'center', label: 'Center' },
1312
+ { value: 'right', label: 'Right' },
1313
+ ],
1314
+ },
1315
+ {
1316
+ id: 'showBreadcrumbs',
1317
+ type: 'boolean',
1318
+ label: 'Show Breadcrumbs',
1319
+ defaultValue: false,
1320
+ required: false,
1321
+ },
1322
+ ],
1323
+ }
1324
+ ```
1325
+
1326
+ **Adding metadata fields:**
1327
+ ```typescript
1328
+ 'block.blog-listing': {
1329
+ fields: [
1330
+ {
1331
+ id: 'analyticsId',
1332
+ type: 'text',
1333
+ label: 'Analytics ID',
1334
+ description: 'Track this specific listing in analytics',
1335
+ required: false,
1336
+ multiline: false,
1337
+ },
1338
+ ],
1339
+ }
1340
+ ```
1341
+
1342
+ ### Comparison: blockFieldOptions vs blockFieldExtensions
1343
+
1344
+ | Feature | `blockFieldOptions` | `blockFieldExtensions` |
1345
+ |---------|---------------------|------------------------|
1346
+ | Purpose | Override options for existing select fields | Add new fields to blocks |
1347
+ | Supports | Select field options only | All field types |
1348
+ | Use case | Customize dropdown choices | Add new data fields |
1349
+ | Block types | System and custom blocks | System blocks only |
1350
+
1351
+ Use **both together** for maximum customization:
1352
+
1353
+ ```typescript
1354
+ export default defineConfig({
1355
+ siteId: 'your-site-id',
1356
+
1357
+ // Override options for existing fields
1358
+ blockFieldOptions: {
1359
+ 'block.embed': {
1360
+ layout: {
1361
+ options: [
1362
+ { value: 'blog-grid', label: 'Blog Grid' },
1363
+ { value: 'team-cards', label: 'Team Cards' },
1364
+ ],
1365
+ },
1366
+ },
1367
+ },
1368
+
1369
+ // Add new fields to blocks
1370
+ blockFieldExtensions: {
1371
+ 'block.body-text': {
1372
+ fields: [
1373
+ {
1374
+ id: 'layout',
1375
+ type: 'select',
1376
+ label: 'Layout',
1377
+ defaultValue: 'default',
1378
+ required: false,
1379
+ multiple: false,
1380
+ options: [
1381
+ { value: 'default', label: 'Default' },
1382
+ { value: 'wide', label: 'Wide' },
1383
+ ],
1384
+ },
1385
+ ],
1386
+ },
1387
+ },
1388
+ });
1389
+ ```
1390
+
1391
+ ## Custom Blocks
1392
+
1393
+ Define site-specific blocks directly in your SDK configuration. Custom blocks appear in the CMS block picker alongside system blocks, are edited using CMS-generated forms, and are rendered by your own React components.
1394
+
1395
+ ### Quick Start
1396
+
1397
+ 1. **Define the block schema** in your `riverbank.config.ts`:
1398
+
1399
+ ```typescript
1400
+ import { defineConfig } from '@riverbankcms/sdk/config';
1401
+
1402
+ export default defineConfig({
1403
+ siteId: 'your-site-id',
1404
+ customBlocks: [
1405
+ {
1406
+ id: 'custom.team-member',
1407
+ title: 'Team Member',
1408
+ titleSource: 'name',
1409
+ description: 'Display a team member with photo and bio',
1410
+ category: 'content',
1411
+ icon: 'User',
1412
+ tags: ['team', 'about'],
1413
+ fields: [
1414
+ { id: 'name', type: 'text', label: 'Name', required: true, multiline: false },
1415
+ { id: 'role', type: 'text', label: 'Role', required: false, multiline: false },
1416
+ { id: 'photo', type: 'media', label: 'Photo', required: false, mediaKinds: ['image'] },
1417
+ { id: 'bio', type: 'richText', label: 'Bio', required: false },
1418
+ ],
1419
+ },
1420
+ ],
1421
+ });
1422
+ ```
1423
+
1424
+ 2. **Create your component** to render the block:
1425
+
1426
+ ```tsx
1427
+ // components/blocks/TeamMember.tsx
1428
+ import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
1429
+
1430
+ type TeamMemberContent = {
1431
+ name: string;
1432
+ role?: string;
1433
+ photo?: { url: string; alt?: string };
1434
+ bio?: string;
1435
+ };
1436
+
1437
+ export function TeamMember({ content }: SystemBlockComponentProps<TeamMemberContent>) {
1438
+ return (
1439
+ <div className="flex items-start gap-6 p-6">
1440
+ {content.photo && (
1441
+ <img
1442
+ src={content.photo.url}
1443
+ alt={content.photo.alt || content.name}
1444
+ className="h-24 w-24 rounded-full object-cover"
1445
+ />
1446
+ )}
1447
+ <div>
1448
+ <h3 className="text-xl font-bold">{content.name}</h3>
1449
+ {content.role && <p className="text-gray-600">{content.role}</p>}
1450
+ {content.bio && (
1451
+ <div className="mt-2 prose" dangerouslySetInnerHTML={{ __html: content.bio }} />
1452
+ )}
1453
+ </div>
1454
+ </div>
1455
+ );
1456
+ }
1457
+ ```
1458
+
1459
+ 3. **Register the component** with `blockOverrides`:
1460
+
1461
+ ```tsx
1462
+ // app/[...slug]/page.tsx
1463
+ import { loadPage, Page } from '@riverbankcms/sdk';
1464
+ import { TeamMember } from '@/components/blocks/TeamMember';
1465
+
1466
+ export default async function CMSPage({ params }) {
1467
+ const pageData = await loadPage({
1468
+ client,
1469
+ siteId: process.env.SITE_ID!,
1470
+ path: `/${params.slug?.join('/') || ''}`,
1471
+ });
1472
+
1473
+ return (
1474
+ <Page
1475
+ {...pageData}
1476
+ blockOverrides={{
1477
+ 'custom.team-member': TeamMember,
1478
+ }}
1479
+ />
1480
+ );
1481
+ }
1482
+ ```
1483
+
1484
+ ### Block Definition Schema
1485
+
1486
+ | Property | Type | Required | Description |
1487
+ |----------|------|----------|-------------|
1488
+ | `id` | `custom.${string}` | Yes | Unique block ID, must start with `custom.` |
1489
+ | `title` | `string` | Yes | Display name in block picker |
1490
+ | `titleSource` | `string` | No | Field ID to use as block title in lists |
1491
+ | `description` | `string` | No | Description shown in block picker |
1492
+ | `category` | `BlockCategory` | Yes | One of: `marketing`, `content`, `blog`, `media`, `layout`, `interactive` |
1493
+ | `icon` | `string` | No | Lucide icon name (e.g., `User`, `CreditCard`) |
1494
+ | `tags` | `string[]` | No | Search tags for discoverability |
1495
+ | `fields` | `FieldDefinition[]` | Yes | Field definitions for the block |
1496
+
1497
+ ### Supported Field Types
1498
+
1499
+ All field types from the CMS are supported:
1500
+
1501
+ **Basic Types:**
1502
+ - `text` - Single or multiline text
1503
+ - `richText` - Rich text editor with formatting
1504
+ - `number` - Numeric input
1505
+ - `boolean` - Toggle/checkbox
1506
+
1507
+ **Media & Links:**
1508
+ - `media` - Image, video, or file upload
1509
+ - `url` - URL input with validation
1510
+ - `link` - Internal or external link picker
1511
+
1512
+ **Selection:**
1513
+ - `select` - Dropdown selection
1514
+ - `reference` - Reference to other content entries
1515
+
1516
+ **Date/Time:**
1517
+ - `date` - Date picker
1518
+ - `time` - Time picker
1519
+ - `datetime` - Combined date and time
1520
+
1521
+ **Complex Types:**
1522
+ - `group` - Group related fields together
1523
+ - `repeater` - Repeatable list of items
1524
+ - `modal` - Fields in a modal dialog
1525
+ - `tabGroup` - Tabbed field groups
1526
+
1527
+ ### Examples
1528
+
1529
+ **Pricing Card:**
1530
+
1531
+ ```typescript
1532
+ {
1533
+ id: 'custom.pricing-card',
1534
+ title: 'Pricing Card',
1535
+ titleSource: 'planName',
1536
+ category: 'marketing',
1537
+ icon: 'CreditCard',
1538
+ fields: [
1539
+ { id: 'planName', type: 'text', label: 'Plan Name', required: true, multiline: false },
1540
+ { id: 'price', type: 'text', label: 'Price', required: false, multiline: false, description: 'e.g., "$29/mo"' },
1541
+ { id: 'featured', type: 'boolean', label: 'Featured Plan', required: false },
1542
+ {
1543
+ id: 'features',
1544
+ type: 'repeater',
1545
+ label: 'Features',
1546
+ required: false,
1547
+ itemLabel: 'Feature',
1548
+ maxItems: 10,
1549
+ fields: [
1550
+ { id: 'text', type: 'text', label: 'Feature', required: true, multiline: false },
1551
+ { id: 'included', type: 'boolean', label: 'Included', required: false },
1552
+ ],
1553
+ },
1554
+ { id: 'ctaLabel', type: 'text', label: 'Button Label', required: false, multiline: false },
1555
+ { id: 'ctaLink', type: 'link', label: 'Button Link', required: false },
1556
+ ],
1557
+ }
1558
+ ```
1559
+
1560
+ **Testimonial:**
1561
+
1562
+ ```typescript
1563
+ {
1564
+ id: 'custom.testimonial',
1565
+ title: 'Testimonial',
1566
+ titleSource: 'authorName',
1567
+ category: 'content',
1568
+ icon: 'Quote',
1569
+ fields: [
1570
+ { id: 'quote', type: 'richText', label: 'Quote', required: true },
1571
+ { id: 'authorName', type: 'text', label: 'Author Name', required: true, multiline: false },
1572
+ { id: 'authorTitle', type: 'text', label: 'Author Title', required: false, multiline: false },
1573
+ { id: 'authorPhoto', type: 'media', label: 'Author Photo', required: false, mediaKinds: ['image'] },
1574
+ { id: 'companyLogo', type: 'media', label: 'Company Logo', required: false, mediaKinds: ['image'] },
1575
+ { id: 'rating', type: 'number', label: 'Rating (1-5)', required: false },
1576
+ ],
1577
+ }
1578
+ ```
1579
+
1580
+ ### Validation Rules
1581
+
1582
+ - Maximum 20 custom blocks per site
1583
+ - Block IDs must be unique within the site
1584
+ - Block IDs must start with `custom.` followed by lowercase letters, numbers, or hyphens
1585
+ - `titleSource` must reference a valid field ID if provided
1586
+ - At least one field is required per block
1587
+ - Maximum 5 data loaders per block
1588
+
1589
+ ### Best Practices
1590
+
1591
+ 1. **Use descriptive IDs**: `custom.team-member` is better than `custom.tm`
1592
+ 2. **Set `titleSource`**: Helps editors identify blocks in the page structure
1593
+ 3. **Add descriptions**: Help editors understand when to use each block
1594
+ 4. **Group related fields**: Use `group` for related fields that should appear together
1595
+ 5. **Validate required fields**: Mark essential fields as `required: true`
1596
+ 6. **Provide defaults**: Use `defaultValue` for optional fields with sensible defaults
1597
+
1598
+ ## Custom Block Data Loaders
1599
+
1600
+ Custom blocks can fetch data automatically using data loaders. There are two approaches:
1601
+
1602
+ 1. **Config-based loaders** - Declarative loaders defined in `riverbank.config.ts` for whitelisted CMS endpoints
1603
+ 2. **Code-based loaders** - Functions passed to `loadPage()` for external APIs
1604
+
1605
+ Both loader types run server-side during `loadPage()`. Data is passed to your component via the `data` prop.
1606
+
1607
+ ### Config-Based Loaders (CMS Endpoints)
1608
+
1609
+ Define loaders declaratively in your block definition. These are restricted to whitelisted CMS endpoints for security.
1610
+
1611
+ **Supported Endpoints:**
1612
+
1613
+ | Endpoint | Description |
1614
+ |----------|-------------|
1615
+ | `listPublishedEntries` | Fetch published content entries |
1616
+ | `getPublishedEntryPreview` | Fetch a single entry by slug |
1617
+ | `listPublicEvents` | Fetch events |
1618
+ | `getPublicFormById` | Fetch form configuration |
1619
+ | `getPublicBookingServices` | Fetch booking services |
1620
+
1621
+ **Parameter Bindings:**
1622
+
1623
+ Loader params can be static values or dynamic bindings:
1624
+
1625
+ - `{ $bind: { from: 'content.fieldName' } }` - Bind to a block field value
1626
+ - `{ $bind: { from: 'content.fieldName', fallback: '10' } }` - With fallback
1627
+ - `{ $bind: { from: '$root.siteId' } }` - Bind to site ID from page context
1628
+ - `{ $bind: { from: '$root.pageId' } }` - Bind to page ID
1629
+ - `{ $bind: { from: '$root.previewStage' } }` - 'published' or 'preview'
1630
+
1631
+ **Example: Blog Listing Block**
1632
+
1633
+ ```typescript
1634
+ // riverbank.config.ts
1635
+ import { defineConfig } from '@riverbankcms/sdk/config';
1636
+
1637
+ export default defineConfig({
1638
+ siteId: 'your-site-id',
1639
+ customBlocks: [
1640
+ {
1641
+ id: 'custom.featured-posts',
1642
+ title: 'Featured Posts',
1643
+ category: 'blog',
1644
+ icon: 'FileText',
1645
+ fields: [
1646
+ { id: 'limit', type: 'number', label: 'Number of posts', required: false },
1647
+ { id: 'category', type: 'text', label: 'Category filter', required: false, multiline: false },
1648
+ ],
1649
+ dataLoaders: {
1650
+ posts: {
1651
+ endpoint: 'listPublishedEntries',
1652
+ params: {
1653
+ siteId: { $bind: { from: '$root.siteId' } },
1654
+ type: 'blog-post',
1655
+ limit: { $bind: { from: 'content.limit', fallback: '10' } },
1656
+ },
1657
+ },
1658
+ },
1659
+ },
1660
+ ],
1661
+ });
1662
+ ```
1663
+
1664
+ ```tsx
1665
+ // components/blocks/FeaturedPosts.tsx
1666
+ import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
1667
+
1668
+ type FeaturedPostsContent = {
1669
+ limit?: number;
1670
+ category?: string;
1671
+ };
1672
+
1673
+ type FeaturedPostsData = {
1674
+ posts: { entries: Array<{ id: string; title: string; slug: string }> };
1675
+ };
1676
+
1677
+ export function FeaturedPosts({
1678
+ content,
1679
+ data,
1680
+ }: SystemBlockComponentProps<FeaturedPostsContent, FeaturedPostsData>) {
1681
+ const posts = data?.posts?.entries ?? [];
1682
+
1683
+ return (
1684
+ <div className="grid gap-4">
1685
+ {posts.map((post) => (
1686
+ <article key={post.id}>
1687
+ <a href={`/blog/${post.slug}`}>
1688
+ <h3>{post.title}</h3>
1689
+ </a>
1690
+ </article>
1691
+ ))}
1692
+ </div>
1693
+ );
1694
+ }
1695
+ ```
1696
+
1697
+ ### Code-Based Loaders (External APIs)
1698
+
1699
+ For data from external APIs, pass loader functions to `loadPage()`. This gives you full control over the fetch logic.
1700
+
1701
+ **Example: E-commerce Product Block**
1702
+
1703
+ ```typescript
1704
+ // app/[...slug]/page.tsx
1705
+ import { loadPage, Page } from '@riverbankcms/sdk';
1706
+ import { FeaturedProducts } from '@/components/blocks/FeaturedProducts';
1707
+
1708
+ export default async function CMSPage({ params }) {
1709
+ const pageData = await loadPage({
1710
+ client,
1711
+ siteId: process.env.SITE_ID!,
1712
+ path: `/${params.slug?.join('/') || ''}`,
1713
+ dataLoaderOverrides: {
1714
+ 'custom.featured-products': {
1715
+ products: async (ctx) => {
1716
+ // Access block content for parameters
1717
+ const categoryId = ctx.content.categoryId as string;
1718
+ const limit = (ctx.content.limit as number) ?? 10;
1719
+
1720
+ const res = await fetch(
1721
+ `https://api.shop.com/products?category=${categoryId}&limit=${limit}`,
1722
+ { headers: { 'Authorization': `Bearer ${process.env.SHOP_API_KEY}` } }
1723
+ );
1724
+
1725
+ return res.json();
1726
+ },
1727
+ },
1728
+ },
1729
+ });
1730
+
1731
+ return (
1732
+ <Page
1733
+ {...pageData}
1734
+ blockOverrides={{
1735
+ 'custom.featured-products': FeaturedProducts,
1736
+ }}
1737
+ />
1738
+ );
1739
+ }
1740
+ ```
1741
+
1742
+ ```tsx
1743
+ // components/blocks/FeaturedProducts.tsx
1744
+ import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
1745
+
1746
+ type ProductsContent = {
1747
+ categoryId: string;
1748
+ limit?: number;
1749
+ };
1750
+
1751
+ type ProductsData = {
1752
+ products: Array<{ id: string; name: string; price: number; image: string }>;
1753
+ };
1754
+
1755
+ export function FeaturedProducts({
1756
+ content,
1757
+ data,
1758
+ }: SystemBlockComponentProps<ProductsContent, ProductsData>) {
1759
+ const products = data?.products ?? [];
1760
+
1761
+ return (
1762
+ <div className="grid grid-cols-3 gap-6">
1763
+ {products.map((product) => (
1764
+ <div key={product.id} className="border rounded p-4">
1765
+ <img src={product.image} alt={product.name} />
1766
+ <h3>{product.name}</h3>
1767
+ <p>${product.price}</p>
1768
+ </div>
1769
+ ))}
1770
+ </div>
1771
+ );
1772
+ }
1773
+ ```
1774
+
1775
+ ### DataLoaderContext
1776
+
1777
+ Code-based loaders receive context about the block and page:
1778
+
1779
+ ```typescript
1780
+ interface DataLoaderContext {
1781
+ /** Site ID from page context */
1782
+ siteId: string;
1783
+
1784
+ /** Page ID from page context */
1785
+ pageId: string;
1786
+
1787
+ /** Unique block instance ID */
1788
+ blockId: string;
1789
+
1790
+ /** Block kind (e.g., 'custom.featured-products') */
1791
+ blockKind: string;
1792
+
1793
+ /** The block's CMS content (field values) */
1794
+ content: Record<string, unknown>;
1795
+
1796
+ /** Whether fetching preview/draft content */
1797
+ previewStage: 'published' | 'preview';
1798
+ }
1799
+ ```
1800
+
1801
+ ### Combining Config and Code Loaders
1802
+
1803
+ You can use both loader types together. Config loaders run first, then code loaders. On key conflicts, code loaders take precedence.
1804
+
1805
+ ```typescript
1806
+ // riverbank.config.ts - Config loader for CMS data
1807
+ customBlocks: [{
1808
+ id: 'custom.product-showcase',
1809
+ dataLoaders: {
1810
+ relatedPosts: {
1811
+ endpoint: 'listPublishedEntries',
1812
+ params: {
1813
+ siteId: { $bind: { from: '$root.siteId' } },
1814
+ type: 'blog-post',
1815
+ limit: '3',
1816
+ },
1817
+ },
1818
+ },
1819
+ }]
1820
+
1821
+ // page.tsx - Code loader for external API
1822
+ const pageData = await loadPage({
1823
+ client,
1824
+ siteId,
1825
+ path: '/',
1826
+ dataLoaderOverrides: {
1827
+ 'custom.product-showcase': {
1828
+ products: async (ctx) => fetchProductsFromShopify(ctx.content.collectionId),
1829
+ },
1830
+ },
1831
+ });
1832
+
1833
+ // Component receives both: data.relatedPosts (CMS) and data.products (Shopify)
1834
+ ```
1835
+
1836
+ ### Error Handling
1837
+
1838
+ Data loaders are best-effort. If a loader fails:
1839
+
1840
+ - The error is logged to console
1841
+ - The loader key is undefined in the `data` prop
1842
+ - Other loaders continue executing
1843
+ - Your component should handle missing data gracefully
1844
+
1845
+ ```tsx
1846
+ function MyBlock({ data }: SystemBlockComponentProps<Content, Data>) {
1847
+ // Handle missing data
1848
+ const products = data?.products ?? [];
1849
+
1850
+ if (products.length === 0) {
1851
+ return <p>No products available</p>;
1852
+ }
1853
+
1854
+ return <ProductGrid products={products} />;
1855
+ }
1856
+ ```
1857
+
1858
+ ### Data Exports
1859
+
1860
+ Import data utilities from `@riverbankcms/sdk/data`:
1861
+
1862
+ ```typescript
1863
+ import {
1864
+ // Core prefetch function
1865
+ prefetchBlockData,
1866
+
1867
+ // Code loader execution
1868
+ executeCodeLoaders,
1869
+ mergeLoaderResults,
1870
+
1871
+ // Types
1872
+ type DataLoaderContext,
1873
+ type DataLoaderFn,
1874
+ type BlockLoaderMap,
1875
+ type DataLoaderOverrides,
1876
+ type PrefetchContext,
1877
+ type ResolvedBlockData,
1878
+ type SdkPrefetchOptions,
1879
+ } from '@riverbankcms/sdk/data';
1880
+ ```
1881
+
1882
+ ## Additional Exports
1883
+
1884
+ - `@riverbankcms/sdk/rendering` - Low-level rendering components
1885
+ - `@riverbankcms/sdk/theme` - Theme utilities
1886
+ - `@riverbankcms/sdk/theme-bridge` - Simplified theming for SDK sites
1887
+ - `@riverbankcms/sdk/hooks` - (Deprecated: use `/client` instead)
1888
+ - `@riverbankcms/sdk/data` - Data prefetching utilities
1889
+ - `@riverbankcms/sdk/metadata` - Metadata generation helpers
1890
+ - `@riverbankcms/sdk/routing` - Route resolution helpers
1891
+ - `@riverbankcms/sdk/analytics` - Analytics tracking helpers
1892
+ - `@riverbankcms/sdk/config` - Site configuration utilities (includes `defineConfig`, `RiverbankSiteConfig`, `BlockFieldOptionsMap`, `FieldSelectOption`, etc.)