@riverbankcms/sdk
v0.14.0
Published
Riverbank CMS SDK for headless content consumption
Maintainers
Readme
@riverbankcms/sdk
A lightweight TypeScript SDK for consuming Riverbank CMS content in React Server Components, Next.js, and client-side React applications.
Features
- ✅ Server-side rendering with
loadPage()helper - ✅ Unified content fetching with
loadContent()for pages and entries - ✅ Client-side data fetching with
usePage()anduseContent()hooks - ✅ Automatic data prefetching for blocks with loaders
- ✅ Custom block data loaders - Config-based (CMS endpoints) and code-based (external APIs)
- ✅ Type-safe API client with caching
- ✅ React Server Components compatible
- ✅ Custom blocks - Define site-specific blocks with full CMS editing support
- ✅ Content scaffolding - Define content types, pages, entries, and navigation in code
- ✅ Site URLs - Configure preview and live URLs for dashboard integration
Installation
npm install @riverbankcms/sdk
# or
pnpm add @riverbankcms/sdkQuick Start
Important: The
baseUrlparameter must be the complete API URL including the/apipath. For example:https://dashboard.example.com/api
Server-Side Rendering (Recommended)
Use in Next.js App Router Server Components or getServerSideProps:
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
// Note: baseUrl must include the /api path
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
export default async function HomePage() {
// Fetch all page data (site, page, block data) in parallel
const pageData = await loadPage({
client,
siteId: 'your-site-id',
path: '/',
});
return <Page {...pageData} />;
}Client-Side Rendering
For client-only React apps or Client Components:
"use client";
import { createRiverbankClient } from '@riverbankcms/sdk';
import { usePage, Page } from '@riverbankcms/sdk/client';
const client = createRiverbankClient({
apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
export function DynamicPage({ path }: { path: string }) {
const pageData = usePage({ client, siteId: 'your-site-id', path });
if (pageData.loading) return <div>Loading...</div>;
if (pageData.error) return <div>Error: {pageData.error.message}</div>;
return <Page {...pageData} />;
}API Reference
Configuration
const client = createRiverbankClient({
apiKey: string; // Required: Builder API key
baseUrl: string; // Required: Full API URL (e.g., 'https://dashboard.example.com/api')
cache?: {
enabled?: boolean; // Default: true
ttl?: number; // Default: 300 (seconds)
maxSize?: number; // Default: 100
};
resilience?: ResilienceConfig; // See "Resilience & Caching" section
});Resilience & Caching
The SDK provides a multi-layer resilience strategy to keep your site running even during CMS outages:
- In-Memory Cache - Fast cache with configurable TTL
- Stale-If-Error - Serves stale cached data when live fetch fails
- Circuit Breaker - Fail-fast after repeated failures (5 by default)
- Prebuild Fallback - Static build artifacts as last resort (site, pages, entries, navigation, forms)
- Retry with Backoff - Automatic retry for transient errors
Basic Configuration:
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.RIVERBANK_BASE_URL!,
cache: {
enabled: true,
ttl: 300, // 5 minutes fresh cache
maxSize: 100,
},
resilience: {
enabled: true,
staleIfError: true,
staleTtlSec: 300, // Additional 5 minutes for stale data
prebuildDir: '.riverbank-cache', // Enable prebuild fallback
maxPrebuildAgeSec: 86400, // 24 hours max prebuild age
},
});Monitoring Callbacks:
resilience: {
// Called on every request with status information
onStatusChange: (status) => {
console.log('SDK request:', {
source: status.source, // 'live' | 'cache' | 'stale' | 'prebuild' | 'error'
circuit: status.circuit.state, // 'closed' | 'open' | 'half-open'
staleAgeSec: status.staleAgeSec,
prebuildAgeSec: status.prebuildAgeSec,
error: status.error?.message,
});
},
// Called only when entering/exiting degraded mode
onDegradedMode: (degraded, status) => {
if (degraded) {
console.warn('CMS degraded mode:', status.source);
// Alert monitoring system
}
},
}Programmatic State Access:
// Get the last request status
const lastStatus = client.getLastEmittedStatus();
// Get current circuit breaker state
const circuitState = client.getCircuitState();
// { state: 'closed', failureCount: 0 }Full Configuration Options:
| Option | Default | Description |
|--------|---------|-------------|
| resilience.enabled | true | Enable resilience features |
| resilience.staleIfError | true | Serve stale data on failure |
| resilience.staleTtlSec | 300 | Additional stale window after cache TTL |
| resilience.requestTimeoutMs | 8000 | Request timeout (5000 in browser) |
| resilience.prebuildDir | - | Path to prebuild cache (enables fallback) |
| resilience.maxPrebuildAgeSec | 86400 | Maximum prebuild age (24h) |
| resilience.retry.maxAttempts | 3 | Total attempts including initial |
| resilience.retry.baseDelayMs | 200 | Base delay between retries |
| resilience.retry.maxDelayMs | 2000 | Maximum retry delay |
| resilience.circuitBreaker.failureThreshold | 5 | Failures before circuit opens |
| resilience.circuitBreaker.resetTimeoutMs | 30000 | Time before half-open |
For detailed testing and verification steps, see docs/resilience-verification.md.
Server-Side API
import { loadPage, Page } from '@riverbankcms/sdk';
// Fetch all page data
const pageData = await loadPage({
client,
siteId: string,
path: string,
preview?: boolean, // Default: false - set true for draft content
pageId?: string, // Optional: explicit page ID
dataLoaderOverrides?: { // Optional: code-based data loaders for custom blocks
'custom.block-id': {
loaderKey: async (ctx) => fetchData(ctx.content.fieldValue),
},
},
});
// Render the page
<Page {...pageData} />Preview Mode (Draft Content)
The SDK supports fetching draft/unpublished content by passing preview: true. This requires an API key with site access.
Server-Side Preview:
// app/preview/[[...slug]]/page.tsx
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
export default async function PreviewPage({ params, searchParams }) {
const pageData = await loadPage({
client,
siteId: searchParams.siteId,
path: `/${params.slug?.join('/') || ''}`,
preview: true, // 🔑 Fetch draft content
});
return (
<div>
<div className="preview-banner">Preview Mode - Draft Content</div>
<Page {...pageData} />
</div>
);
}Client-Side Preview:
"use client";
import { createRiverbankClient } from '@riverbankcms/sdk';
import { usePage, Page } from '@riverbankcms/sdk/client';
const client = createRiverbankClient({
apiKey: process.env.NEXT_PUBLIC_RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
export function PreviewPage({ siteId, path }: { siteId: string; path: string }) {
const pageData = usePage({
client,
siteId,
path,
preview: true, // 🔑 Fetch draft content
});
if (pageData.loading) return <div>Loading preview...</div>;
if (pageData.error) return <div>Error: {pageData.error.message}</div>;
return <Page {...pageData} />;
}How Preview Works:
- ✅ Uses your existing API key (no separate preview tokens needed)
- ✅ Returns draft content for blocks (via
draftContentfield) - ✅ Shows unpublished pages that are in draft state
- ✅ Published and preview content are cached separately
- ✅ Requires API key to be scoped to the site
Use Cases:
- Preview pages before publishing
- Share draft content with stakeholders
- Test content changes in staging environment
- Content editing workflows
Client-Side API
import { usePage, Page } from '@riverbankcms/sdk/client';
const pageData = usePage({
client,
siteId: string,
path: string,
preview?: boolean, // Default: false - set true for draft content
pageId?: string,
});
// pageData is a discriminated union:
// { loading: true, error: null, ... } |
// { loading: false, error: Error, ... } |
// { loading: false, error: null, page, theme, ... }Unified Content Loading (Pages + Entries)
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.
Server-Side: loadContent
import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';
export default async function DynamicRoute({ params }) {
const path = `/${params.slug?.join('/') || ''}`;
const content = await loadContent({
client,
siteId: 'your-site-id',
path,
preview: false, // Set true for draft content
});
// Pages get rendered with the Page component
if (isPageContent(content)) {
return <Page {...content} />;
}
// Entries get rendered with custom UI
return (
<article>
<h1>{content.entry.title}</h1>
<div>{JSON.stringify(content.entry.content)}</div>
</article>
);
}Client-Side: useContent
"use client";
import { useContent, isPageContentResult, Page } from '@riverbankcms/sdk/client';
export function DynamicContent({ path }: { path: string }) {
const content = useContent({
client,
siteId: 'your-site-id',
path,
preview: false,
});
if (content.loading) return <div>Loading...</div>;
if (content.error) return <div>Error: {content.error.message}</div>;
if (isPageContentResult(content)) {
return <Page page={content.page} theme={content.theme} siteId={content.siteId} resolvedData={content.resolvedData} />;
}
// Render entry with custom UI
return (
<article>
<h1>{content.entry.title}</h1>
<p>Type: {content.entry.type}</p>
<div>{JSON.stringify(content.entry.content)}</div>
</article>
);
}Entry-Specific Rendering
Route entries to different components based on their content type:
import { loadContent, isPageContent, Page } from '@riverbankcms/sdk';
export default async function DynamicRoute({ params }) {
const content = await loadContent({ client, siteId, path: `/${params.slug?.join('/') || ''}` });
if (isPageContent(content)) {
return <Page {...content} />;
}
// Route to type-specific components
switch (content.entry.type) {
case 'blog-post':
return <BlogPost entry={content.entry} theme={content.theme} />;
case 'product':
return <ProductPage entry={content.entry} theme={content.theme} />;
case 'event':
return <EventPage entry={content.entry} theme={content.theme} />;
default:
return <GenericEntry entry={content.entry} />;
}
}
function BlogPost({ entry, theme }) {
const { title, content, metaDescription } = entry;
return (
<article>
<h1>{title}</h1>
<p className="lead">{metaDescription}</p>
<div className="prose">{content.body}</div>
<p>By {content.author} on {new Date(entry.createdAt).toLocaleDateString()}</p>
</article>
);
}ContentEntryData Type
When working with entries, you receive raw content data:
type ContentEntryData = {
id: string;
type: string | null; // Content type key (e.g., 'blog-post', 'product')
title: string;
slug: string | null;
path: string | null;
status: string;
publishAt: string | null;
content: Record<string, unknown>; // Raw content fields
metaTitle: string | null;
metaDescription: string | null;
createdAt: string;
updatedAt: string;
};When to Use Which Function
| Function | Use Case |
|----------|----------|
| loadPage() / usePage() | Pages only - throws error on entries |
| loadContent() / useContent() | Both pages and entries - discriminated union |
Fetching Multiple Entries
Use client.getEntries() to fetch lists of content entries for blog listing pages, product catalogs, etc.
Basic Usage
import { createRiverbankClient } from '@riverbankcms/sdk';
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
// Fetch all published blog posts
const { entries } = await client.getEntries({
siteId: 'your-site-id',
contentType: 'blog-post',
});Pagination and Sorting
// Fetch latest 10 posts (newest first)
const { entries: latestPosts } = await client.getEntries({
siteId: 'your-site-id',
contentType: 'blog-post',
limit: 10,
order: 'newest',
});
// Fetch oldest posts first (for archives)
const { entries: archivedPosts } = await client.getEntries({
siteId: 'your-site-id',
contentType: 'blog-post',
order: 'oldest',
});
// Include draft entries for preview mode
const { entries: allPosts } = await client.getEntries({
siteId: 'your-site-id',
contentType: 'blog-post',
preview: true, // Includes unpublished drafts
});Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| siteId | string | Yes | Site ID |
| contentType | string | Yes | Content type key (e.g., 'blog-post') |
| limit | number | No | Maximum entries to return |
| order | 'newest' | 'oldest' | No | Sort by publish date |
| preview | boolean | No | Include draft entries |
Real-World Example: Blog Listing Page
// app/blog/page.tsx
import { createRiverbankClient } from '@riverbankcms/sdk';
import Link from 'next/link';
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
export default async function BlogPage() {
const site = await client.getSite({ slug: 'my-site' });
const { entries: posts } = await client.getEntries({
siteId: site.site.id,
contentType: 'blog-post',
limit: 10,
order: 'newest',
});
return (
<main>
<h1>Blog</h1>
<div className="grid gap-6">
{posts.map((post) => (
<article key={post.id}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.metaDescription}</p>
<time>{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString() : 'Draft'}</time>
</article>
))}
</div>
</main>
);
}Import Patterns
⚠️ Important: Use the correct import path for your environment:
// ✅ Server Components (Next.js App Router)
import { createRiverbankClient, loadPage, Page } from '@riverbankcms/sdk';
// ✅ Client Components (use "use client" directive)
"use client";
import { usePage, Page } from '@riverbankcms/sdk/client';The /client subpath exports React hooks that require client-side React features (useState, useEffect). The main entry point only exports server-safe code.
Navigation
The SDK provides navigation utilities for transforming CMS navigation data into render-ready structures. All navigation functions return nested structures that support both direct links and dropdown menus.
Navigation Types
Navigation items are discriminated unions using a kind field:
import type {
NavItem, // NavLink | NavDropdown
NavLink, // Direct link with href
NavDropdown, // Dropdown container with children
} from '@riverbankcms/sdk/navigation';
// NavLink - a direct navigation link
type NavLink = {
kind: 'link';
id: string;
label: string;
href: string;
isExternal: boolean;
};
// NavDropdown - a dropdown menu container
type NavDropdown = {
kind: 'dropdown';
id: string;
label: string;
children: NavLink[]; // Max 1 level of nesting
};
// NavItem - either a link or dropdown
type NavItem = NavLink | NavDropdown;Type Guards
Use the provided type guards for safe type narrowing:
import { isNavLink, isNavDropdown } from '@riverbankcms/sdk/navigation';
const items = getPrimaryNavItems(siteData.navigation);
items.forEach(item => {
if (isNavLink(item)) {
// TypeScript knows this is NavLink
console.log(item.href);
} else if (isNavDropdown(item)) {
// TypeScript knows this is NavDropdown
console.log(`${item.label} has ${item.children.length} children`);
}
});Or use the kind discriminator directly:
items.forEach(item => {
if (item.kind === 'link') {
console.log(item.href);
} else {
// item.kind === 'dropdown'
console.log(item.children);
}
});Navigation Helper Functions
import {
getPrimaryNavItems,
getNavItemsBySlug,
getPrimaryNavigation,
getNavigationBySlug,
transformToNavItems,
} from '@riverbankcms/sdk/navigation';
// Get nav items from the primary menu (marked isPrimary, or first menu)
const headerNav = getPrimaryNavItems(siteData.navigation);
// Get nav items from a specific menu by name/slug
const footerNav = getNavItemsBySlug(siteData.navigation, 'footer');
// Get the raw menu object (for accessing menu metadata)
const primaryMenu = getPrimaryNavigation(siteData.navigation);
const footerMenu = getNavigationBySlug(siteData.navigation, 'footer');
// Transform a menu object to nav items
const items = transformToNavItems(primaryMenu);Building Menu and Logo
For rendering, use buildMenu and buildLogo which resolve all hrefs from the route map:
import type { Menu, Logo } from '@riverbankcms/sdk/navigation';
import { buildMenu, buildLogo } from '@riverbankcms/sdk/navigation';
// Build menu with pre-resolved hrefs from route map
const menu = buildMenu(siteData.navigation, siteData.routes);
// menu.items is NavItem[] (links and dropdowns)
// menu.ctaItem is NavLink | null (CTA is always a link, never dropdown)
const logo = buildLogo(siteData.layout.logo, siteData.site.title);Rendering Navigation with Dropdowns
import { getPrimaryNavItems, isNavLink, isNavDropdown } from '@riverbankcms/sdk/navigation';
function Header({ navigation }) {
const items = getPrimaryNavItems(navigation);
return (
<nav>
{items.map(item => {
if (isNavLink(item)) {
return (
<a
key={item.id}
href={item.href}
target={item.isExternal ? '_blank' : undefined}
rel={item.isExternal ? 'noopener noreferrer' : undefined}
>
{item.label}
</a>
);
}
// Dropdown
return (
<div key={item.id} className="dropdown">
<button>{item.label}</button>
<div className="dropdown-menu">
{item.children.map(child => (
<a
key={child.id}
href={child.href}
target={child.isExternal ? '_blank' : undefined}
>
{child.label}
</a>
))}
</div>
</div>
);
})}
</nav>
);
}Rendering Menu in Components
import { buildMenu, buildLogo } from '@riverbankcms/sdk/navigation';
function SiteHeader({ siteData }) {
const menu = buildMenu(siteData.navigation, siteData.routes);
const logo = buildLogo(siteData.layout.logo, siteData.site.title);
return (
<header>
{logo && <img src={logo.src} alt={logo.alt} />}
<nav>
{menu.items.map(item => {
if (item.kind === 'link') {
return <a key={item.id} href={item.href}>{item.label}</a>;
}
return (
<div key={item.id} className="dropdown">
<span>{item.label}</span>
<ul>
{item.children.map(child => (
<li key={child.id}>
<a href={child.href}>{child.label}</a>
</li>
))}
</ul>
</div>
);
})}
</nav>
{menu.ctaItem && (
<a href={menu.ctaItem.href} className="cta-button">
{menu.ctaItem.label}
</a>
)}
</header>
);
}Navigation Exports
import {
// Helper functions
getPrimaryNavItems,
getNavItemsBySlug,
getPrimaryNavigation,
getNavigationBySlug,
transformToNavItems,
buildMenu,
buildLogo,
buildMenuViewModel,
buildLogoViewModel,
// Type guards
isNavLink,
isNavDropdown,
// Types
type NavItem,
type NavLink,
type NavDropdown,
type Menu,
type Logo,
type MenuViewModel,
type MenuLinkViewModel,
type MenuCtaViewModel,
type LogoViewModel,
type LogoSource,
type LinkValue,
type InternalLinkValue,
type ExternalLinkValue,
type CustomLinkValue,
} from '@riverbankcms/sdk/navigation';
// For block content link types (page/entry identifier links)
import type {
PageLinkValue, // { kind: 'page', identifier: string }
EntryLinkValue, // { kind: 'entry', contentType: string, identifier: string }
} from '@riverbankcms/sdk/rendering';Layout Component
The Layout component renders the site header, footer, and wraps your page content. Use this when you want consistent site chrome across all pages.
Using Layout with Pre-Fetched Site Data
When you already have site data (from loadPage or client.getSite):
import { Layout } from '@riverbankcms/sdk';
export default async function MyPage() {
const siteData = await client.getSite({ slug: 'my-site' });
return (
<Layout siteData={siteData}>
<main>
<h1>My Custom Page</h1>
<p>This content is wrapped with site header and footer.</p>
</main>
</Layout>
);
}Automatic Site Fetching
Layout can fetch site data automatically if you provide a client and identifier:
import { Layout } from '@riverbankcms/sdk';
export default async function MyPage() {
return (
<Layout
client={client}
slug="my-site" // or siteId="..." or domain="example.com"
>
<main>
<h1>My Custom Page</h1>
</main>
</Layout>
);
}Customizing Header and Footer
You can hide the header or footer:
<Layout
siteData={siteData}
header={false} // Hide header
footer={false} // Hide footer
>
<main>Landing page without navigation</main>
</Layout>Choosing Header Variants
Override the header variant from your code:
<Layout
siteData={siteData}
headerVariant="centered" // Options: classic, centered, transparent, floating, editorial
>
<main>Content with centered header</main>
</Layout>Fully Custom Headers
Create your own header while using CMS navigation data:
import { Layout, type HeaderData } from '@riverbankcms/sdk';
function CustomHeader({ menu, logo, site }: HeaderData) {
return (
<header className="custom-header">
<a href="/">{logo?.url && <img src={logo.url} alt={site.title} />}</a>
<nav>
{menu.items.map(item => (
<a key={item.id} href={item.url.href}>{item.label}</a>
))}
</nav>
</header>
);
}
export default async function MyPage() {
const siteData = await client.getSite({ slug: 'my-site' });
return (
<Layout
siteData={siteData}
header={(data) => <CustomHeader {...data} />}
>
<main>Custom header with CMS navigation</main>
</Layout>
);
}Block Component
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.
Basic Block Rendering
import { Block } from '@riverbankcms/sdk';
export default async function CustomPage() {
const siteData = await client.getSite({ slug: 'my-site' });
return (
<div>
<h1>Custom Header</h1>
{/* Render a CMS block inline */}
<Block
blockKind="block.hero"
content={{
headline: 'Welcome',
subheadline: 'To our custom page',
cta: { label: 'Get Started', href: '/signup' }
}}
theme={siteData.theme}
siteId={siteData.site.id}
/>
<p>More custom content...</p>
</div>
);
}Block with Data Loading
Blocks can automatically load their data (e.g., blog posts, products) if they have data loaders configured:
<Block
blockKind="block.blog-listing"
blockId="block-abc123" // Block ID enables data loading
content={{ maxPosts: 10 }}
theme={siteData.theme}
siteId={siteData.site.id}
pageId={page.id} // Optional: for context-aware loaders
client={client} // Required for data loading
/>Advanced Block Options
<Block
blockKind="block.hero"
blockId="block-xyz"
content={{ heading: 'Welcome' }}
theme={theme}
siteId="site-123"
// Optional data loading context
pageId="page-456"
previewStage="preview" // or "published"
client={client}
// Show placeholder data for missing loaders
usePlaceholders={true}
/>CTA Links with Page/Entry Identifiers
Block content can reference pages and content entries using stable identifiers instead of UUIDs. These links are resolved at render time:
// Link to a page by identifier
const heroContent = {
headline: 'Welcome',
cta: {
kind: 'page',
identifier: 'pricing' // Resolves to page with identifier 'pricing'
}
};
// Link to a content entry (e.g., blog post, team member)
const featuredContent = {
headline: 'Meet Our Team',
cta: {
kind: 'entry',
contentType: 'team-member',
identifier: 'jane-doe' // Resolves to the team member entry
}
};
// Traditional link formats still work
const externalCta = { kind: 'external', href: 'https://example.com' };
const simpleCta = { href: '/contact' }; // Simple hrefWhen using loadPage(), identifier maps are automatically included in the response and passed to the Page component for resolution:
// loadPage() returns pagesByIdentifier and entriesByIdentifier
const pageData = await loadPage({ client, siteId, path: '/' });
// Page component receives these automatically
<Page {...pageData} />Link Value Types:
{ kind: 'page', identifier: string }- Link to a page by its identifier{ kind: 'entry', contentType: string, identifier: string }- Link to a routable content entry{ kind: 'internal', routeId: string, href: string, ... }- Internal link with route ID (from link picker){ kind: 'external', href: string }- External URL{ kind: 'url', href: string }- Custom URL{ href: string }- Simple href (pre-resolved)
Image Resolution
Resolve Media objects to optimized image URLs. Essential for custom blocks that display images.
Basic Usage
import { resolveImageUrl, ImagePresets } from '@riverbankcms/sdk/rendering';
import type { Media } from '@riverbankcms/sdk/rendering';
function MyCustomBlock({ content }: { content: { image: Media } }) {
// Use a preset for common sizes
const imageUrl = resolveImageUrl(content.image, ImagePresets.card);
return <img src={imageUrl} alt={content.image.alt ?? ''} />;
}Image Presets
Use presets for consistent, optimized image sizes:
import { resolveImageUrl, ImagePresets } from '@riverbankcms/sdk/rendering';
// Thumbnail (200px width, 80% quality) - for lists, previews
resolveImageUrl(media, ImagePresets.thumbnail)
// Card (600px width, 80% quality) - for cards, grids
resolveImageUrl(media, ImagePresets.card)
// Hero (1200px width, 85% quality) - for above-fold content
resolveImageUrl(media, ImagePresets.hero)
// Full (1920px width, 85% quality) - for backgrounds, large displays
resolveImageUrl(media, ImagePresets.full)Custom Dimensions
Specify exact dimensions when presets don't fit:
// Width only (height auto-calculated to maintain aspect ratio)
resolveImageUrl(media, { width: 800, quality: 85 })
// Height only (width auto-calculated)
resolveImageUrl(media, { height: 400, quality: 80 })
// Both dimensions
resolveImageUrl(media, { width: 800, height: 600, quality: 85 })Original Resolution
For rare cases like downloads or print, request the original:
// Full resolution - no transforms applied
resolveImageUrl(media, { original: true })Complete Custom Block Example
import { resolveImageUrl, ImagePresets, RichText } from '@riverbankcms/sdk/rendering';
import type { SystemBlockComponentProps, Media } from '@riverbankcms/sdk/rendering';
type TeamMemberContent = {
name: string;
role?: string;
photo?: Media;
bio?: string;
};
export function TeamMember({ content }: SystemBlockComponentProps<TeamMemberContent>) {
const photoUrl = content.photo
? resolveImageUrl(content.photo, ImagePresets.card)
: undefined;
return (
<div className="flex items-start gap-6 p-6">
{photoUrl && (
<img
src={photoUrl}
alt={content.photo?.alt || content.name}
className="h-24 w-24 rounded-full object-cover"
/>
)}
<div>
<h3 className="text-xl font-bold">{content.name}</h3>
{content.role && <p className="text-gray-600">{content.role}</p>}
{content.bio && <RichText content={content.bio} />}
</div>
</div>
);
}How It Works
Images stored in Supabase are served via the transform endpoint when dimensions are specified:
# Without transforms (original resolution - 3000px+)
https://xxx.supabase.co/storage/v1/object/public/media/sites/abc/originals/photo.jpg
# With transforms (optimized - 600px)
https://xxx.supabase.co/storage/v1/render/image/public/media/sites/abc/originals/photo.jpg?width=600&quality=80This reduces image payload significantly (often 10x smaller) while maintaining visual quality.
Notes
- SVG files are returned as direct URLs (transforms don't apply to vectors)
- External URLs (
srcfield) are returned as-is (can't be transformed) - The
Mediatype includesstoragePath,storageBucket,src,alt,identifier, and transform metadata
Metadata Generation
Generate SEO-optimized metadata for Next.js pages from Riverbank CMS data.
Basic Usage
import { generatePageMetadata } from '@riverbankcms/sdk/metadata';
import { loadPage } from '@riverbankcms/sdk';
export async function generateMetadata({ params }) {
const pageData = await loadPage({ client, siteId, path: params.slug });
const siteData = await client.getSite({ id: siteId });
return generatePageMetadata({
page: pageData.page,
site: siteData.site,
path: params.slug || '/',
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
});
}With Custom Overrides
const metadata = generatePageMetadata({
page: pageData.page,
site: siteData.site,
path: '/',
siteUrl: 'https://example.com',
overrides: {
title: 'Custom SEO Title',
description: 'Custom SEO description with keywords',
ogImage: 'https://example.com/og-image.jpg',
canonicalUrl: 'https://example.com/canonical-path',
},
googleSiteVerification: 'verification-token',
});Preview Environment Metadata
import { generatePreviewMetadata } from '@riverbankcms/sdk/metadata';
// Adds noindex/nofollow for staging environments
const metadata = generatePreviewMetadata({
page: pageData.page,
site: siteData.site,
path: '/',
siteUrl: 'https://preview.example.com',
});Route Resolution
Resolve URL paths to pages, redirects, or 404s for dynamic routing.
Single Route Resolution
import { resolveRoute } from '@riverbankcms/sdk/routing';
import { notFound, redirect } from 'next/navigation';
export default async function DynamicPage({ params }) {
const path = `/${params.slug?.join('/') || ''}`;
const resolution = await resolveRoute({
client,
siteId: 'your-site-id',
path,
});
if (resolution.type === 'redirect') {
redirect(resolution.destination);
}
if (resolution.type === 'not-found') {
notFound();
}
return <Page {...resolution.pageData} />;
}Batch Route Resolution
import { resolveRoutes } from '@riverbankcms/sdk/routing';
// Useful for sitemap generation or validation
const resolutions = await resolveRoutes({
client,
siteId: 'your-site-id',
paths: ['/', '/about', '/services', '/contact'],
});
resolutions.forEach(({ path, resolution }) => {
if (resolution.type === 'page') {
console.log(`${path} → Page: ${resolution.pageData.page.name}`);
} else if (resolution.type === 'redirect') {
console.log(`${path} → Redirect to ${resolution.destination}`);
} else {
console.log(`${path} → Not found`);
}
});Forms
Fetch form definitions for rendering in your frontend. Forms include schema (field definitions) and settings (submit behavior, notifications).
Fetching Forms
import { createRiverbankClient } from '@riverbankcms/sdk';
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.NEXT_PUBLIC_DASHBOARD_URL + '/api',
});
// Fetch all forms for a site
const { forms } = await client.getForms({
siteId: 'your-site-id',
});
// Each form includes:
// - id: Form UUID
// - slug: URL-friendly identifier
// - name: Display name
// - schema: Field definitions
// - settings: Submit behavior, notificationsForms with Prebuild Fallback
Forms support the same resilience features as other content. When prebuild is configured, forms are cached at build time and served as a fallback when the CMS is unavailable:
const client = createRiverbankClient({
apiKey: process.env.RIVERBANK_API_KEY!,
baseUrl: process.env.RIVERBANK_BASE_URL!,
resilience: {
prebuildDir: '.riverbank-cache', // Forms are cached here
},
});
// If CMS is down, cached forms are returned
const { forms } = await client.getForms({ siteId });Note: While forms can be rendered from cache during CMS outages, form submissions will fail since they require the live API.
Rendering Forms
Forms provide schema and settings for building form UI:
function DynamicForm({ form }) {
const handleSubmit = async (data) => {
await fetch('/api/forms/submit', {
method: 'POST',
body: JSON.stringify({ formId: form.id, data }),
});
};
return (
<form onSubmit={handleSubmit}>
{form.schema.fields.map((field) => (
<FormField key={field.name} field={field} />
))}
<button type="submit">Submit</button>
</form>
);
}Analytics
Track page views, CTA clicks, form submissions, and custom events.
Add Analytics to Layout
import { AnalyticsBootstrap } from '@riverbankcms/sdk/analytics';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Auto-tracks page views on navigation */}
<AnalyticsBootstrap
siteId="your-site-id"
siteSlug="your-site-slug"
endpoint="/api/analytics/collect"
/>
</body>
</html>
);
}Track Events in Components
'use client';
import { useAnalytics } from '@riverbankcms/sdk/analytics';
export function MyComponent() {
const analytics = useAnalytics({
siteId: 'your-site-id',
siteSlug: 'your-site-slug',
});
const handleClick = () => {
analytics.trackCtaClick({ buttonLabel: 'Sign Up' });
};
const handleSubmit = () => {
analytics.trackFormSubmit({ formName: 'contact-form' });
};
const handleCustomEvent = () => {
analytics.trackEvent({
eventType: 'video_play',
metadata: { videoId: '123', duration: 120 },
});
};
return (
<>
<button onClick={handleClick}>Sign Up</button>
<form onSubmit={handleSubmit}>...</form>
</>
);
}Theming
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.
Quick Start
Wrap your app with ThemeBridgeProvider and define your color tokens:
import { ThemeBridgeProvider } from '@riverbankcms/sdk/theme-bridge';
export default function RootLayout({ children }) {
return (
<ThemeBridgeProvider
config={{
tokens: {
primary: '#6d28d9',
secondary: '#4c1d95',
background: '#ffffff',
text: '#1e293b',
},
}}
>
{children}
</ThemeBridgeProvider>
);
}Token System
Define color tokens as key-value pairs. Keys become CSS variables (--color-{key}):
<ThemeBridgeProvider
config={{
tokens: {
// Brand colors
primary: '#6d28d9',
secondary: '#4c1d95',
// Backgrounds
background: '#ffffff',
surface: '#f8fafc',
// Text
text: '#1e293b',
mutedText: '#64748b',
// UI
border: '#e2e8f0',
white: '#ffffff',
// Status
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
},
}}
>Token values can be:
- Hex colors:
'#6d28d9'(converted to RGB for Tailwind alpha support) - CSS variable refs:
'var(--brand-purple)'(passed through) - RGB values:
'109 40 217'(used directly)
Design Presets
Control typography, spacing, corners, and shadows:
<ThemeBridgeProvider
config={{
tokens: { /* ... */ },
// Typography
typography: {
headingFamily: '"Inter", sans-serif',
bodyFamily: '"Inter", sans-serif',
headingWeight: 700,
bodyWeight: 400,
},
// Spacing density
spacing: 'standard', // 'comfortable' | 'standard' | 'dense'
// Corner radius
corners: 'rounded', // 'square' | 'soft' | 'rounded' | 'pill'
// Shadow intensity
shadows: 'medium', // 'none' | 'low' | 'medium' | 'high'
}}
>Component CSS (Opt-in)
By default, only CSS variables are generated. Enable component CSS for buttons, cards, and inputs:
<ThemeBridgeProvider
config={{
tokens: {
primary: '#6d28d9',
secondary: '#4c1d95',
white: '#ffffff',
surface: '#f8fafc',
text: '#1e293b',
border: '#e2e8f0',
},
corners: 'rounded',
shadows: 'medium',
components: {
buttons: true, // Generates .button-primary, .button-secondary, etc.
cards: true, // Generates .card-default, .card-elevated, etc.
inputs: true, // Generates .form-input, .form-label, etc.
},
}}
>Custom Component Variants
Specify which variants to generate:
components: {
buttons: {
variants: ['primary', 'secondary'], // Only these variants
},
cards: {
variants: ['default', 'elevated'],
},
}CSS Overrides
Add custom CSS rules scoped to theme components:
<ThemeBridgeProvider
config={{
tokens: { /* ... */ },
components: { buttons: true },
overrides: {
'.button-primary': 'border-radius: 9999px; font-weight: 700;',
'.button-primary:hover': 'transform: translateY(-2px);',
},
}}
>Pass-Through to Design Systems
Reference existing CSS variables from your design system:
<ThemeBridgeProvider
config={{
tokens: {
primary: 'var(--brand-purple)',
secondary: 'var(--brand-navy)',
background: 'var(--ds-bg)',
text: 'var(--ds-text)',
},
components: { buttons: true },
}}
>Using Generated CSS Directly
For advanced use cases, generate CSS without the provider:
import { generateThemeBridgeCss } from '@riverbankcms/sdk/theme-bridge';
const { css, cssVars } = generateThemeBridgeCss({
tokens: {
primary: '#6d28d9',
background: '#ffffff',
},
components: { buttons: true },
});
// css: Full CSS string for injection
// cssVars: Object of CSS variable name-value pairsAvailable Exports
import {
// Provider component
ThemeBridgeProvider,
useThemeBridgeCss,
// CSS generator
generateThemeBridgeCss,
// Types
type ThemeBridgeConfig,
type ThemeBridgeTypography,
type ThemeBridgeSpacing,
type ThemeBridgeCorners,
type ThemeBridgeShadows,
type ThemeBridgeComponents,
type ThemeBridgeOutput,
} from '@riverbankcms/sdk/theme-bridge';Block Field Options
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.
Basic Configuration
Add blockFieldOptions to your riverbank.config.ts:
import { defineConfig } from '@riverbankcms/sdk/config';
export default defineConfig({
siteId: 'your-site-id',
blockFieldOptions: {
'block.embed': {
layout: {
options: [
{ value: 'showcase', label: 'Showcase Grid' },
{ value: 'list', label: 'Simple List' },
{ value: 'featured', label: 'Featured Hero' },
]
}
}
}
});How It Works
- Block ID format: Use
block.*for system blocks orcustom.*for custom blocks - Field ID: The field within the block whose options you want to override
- Options: Array of
{ value, label }objects that replace the default options
When a block field uses the sdkSelect widget (like the embed block's layout field), it will:
- Use SDK-provided options when available in
blockFieldOptions - Fall back to the field's default options if no SDK options are configured
Types
import type {
FieldSelectOption,
BlockFieldConfig,
BlockFieldOptionsMap,
} from '@riverbankcms/sdk/config';
// A single select option
type FieldSelectOption = {
value: string; // Value stored when selected
label: string; // Display text in dropdown
};
// Configuration for a field
type BlockFieldConfig = {
options?: FieldSelectOption[]; // Override select options
};
// Map of block IDs to field configurations
type BlockFieldOptionsMap = Record<string, Record<string, BlockFieldConfig>>;Current Use Cases
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:
export default defineConfig({
siteId: 'your-site-id',
blockFieldOptions: {
'block.embed': {
layout: {
options: [
{ value: 'blog-grid', label: 'Blog Grid' },
{ value: 'team-cards', label: 'Team Cards' },
{ value: 'portfolio', label: 'Portfolio Gallery' },
]
}
}
}
});Then in your block override, handle each layout:
function EmbedRenderer({ content, data }) {
switch (content.layout) {
case 'blog-grid':
return <BlogGrid entries={data.entries} />;
case 'team-cards':
return <TeamCards entries={data.entries} />;
case 'portfolio':
return <PortfolioGallery entries={data.entries} />;
default:
return <DefaultList entries={data.entries} />;
}
}Validation Rules
- Block IDs must match pattern:
block.*orcustom.*(lowercase letters, numbers, hyphens) - Field IDs must be non-empty strings
- Each option must have both
valueandlabel(non-empty strings) - At least one option is required when
optionsis specified
Block Field Extensions
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.
Basic Configuration
Add blockFieldExtensions to your riverbank.config.ts:
import { defineConfig } from '@riverbankcms/sdk/config';
export default defineConfig({
siteId: 'your-site-id',
blockFieldExtensions: {
'block.body-text': {
fields: [
{
id: 'layout',
type: 'select',
label: 'Layout',
defaultValue: 'default',
required: false,
multiple: false,
options: [
{ value: 'default', label: 'Default' },
{ value: 'wide', label: 'Wide' },
{ value: 'narrow', label: 'Narrow' },
{ value: 'pullquote', label: 'Pull Quote' },
],
},
],
},
'block.hero': {
fields: [
{
id: 'videoBackground',
type: 'media',
label: 'Video Background',
description: 'Optional video to play behind the hero',
mediaKinds: ['video'],
required: false,
},
{
id: 'overlayOpacity',
type: 'number',
label: 'Overlay Opacity',
description: 'Darkness of the overlay (0-100)',
defaultValue: 50,
required: false,
},
],
},
},
});How It Works
- Extended fields appear at the end of the block's editing form in the CMS
- Field values are stored alongside normal block content
- Values are accessible in your
blockOverridesvia thecontentprop - All field types supported: text, select, media, repeater, group, etc.
Accessing Extended Fields in blockOverrides
import { loadPage, Page } from '@riverbankcms/sdk';
export default async function CMSPage({ params }) {
const pageData = await loadPage({ client, siteId, path: '/' });
return (
<Page
{...pageData}
blockOverrides={{
bodyText: ({ content }) => {
// Extended field values are available on content
const layout = content.layout ?? 'default';
const layoutClass = {
default: 'max-w-prose mx-auto',
wide: 'max-w-4xl mx-auto',
narrow: 'max-w-md mx-auto',
pullquote: 'max-w-lg mx-auto text-xl italic border-l-4 pl-6',
}[layout];
return (
<div className={layoutClass}>
<RichText content={content.body} />
</div>
);
},
hero: ({ content }) => {
const { videoBackground, overlayOpacity = 50 } = content;
return (
<div className="relative">
{videoBackground && (
<video
src={videoBackground.url}
className="absolute inset-0 w-full h-full object-cover"
autoPlay
muted
loop
/>
)}
<div
className="absolute inset-0 bg-black"
style={{ opacity: overlayOpacity / 100 }}
/>
<div className="relative z-10">
<h1>{content.headline}</h1>
</div>
</div>
);
},
}}
/>
);
}Types
import type {
BlockFieldExtension,
BlockFieldExtensionsMap,
FieldDefinition,
} from '@riverbankcms/sdk/config';
// Configuration for extending a single block
type BlockFieldExtension = {
fields: FieldDefinition[]; // Same field format as block manifests
};
// Map of block IDs to their extensions
type BlockFieldExtensionsMap = Record<string, BlockFieldExtension>;Validation Rules
- Block IDs must be system blocks: Only
block.*format (e.g.,block.body-text,block.hero). Custom blocks (custom.*) should define their fields directly incustomBlocks. - 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.
- Required fields need defaultValue: If
required: true, you must provide adefaultValue. This ensures existing blocks (created before the extension) can still be edited. - At least one field required: Each block extension must have at least one field.
Use Cases
Layout variations for text blocks:
'block.body-text': {
fields: [
{
id: 'layout',
type: 'select',
label: 'Layout',
defaultValue: 'default',
required: false,
multiple: false,
options: [
{ value: 'default', label: 'Default' },
{ value: 'wide', label: 'Wide' },
{ value: 'sidebar', label: 'With Sidebar' },
],
},
],
}Custom styling options:
'block.hero': {
fields: [
{
id: 'textAlignment',
type: 'select',
label: 'Text Alignment',
defaultValue: 'center',
required: false,
multiple: false,
options: [
{ value: 'left', label: 'Left' },
{ value: 'center', label: 'Center' },
{ value: 'right', label: 'Right' },
],
},
{
id: 'showBreadcrumbs',
type: 'boolean',
label: 'Show Breadcrumbs',
defaultValue: false,
required: false,
},
],
}Adding metadata fields:
'block.blog-listing': {
fields: [
{
id: 'analyticsId',
type: 'text',
label: 'Analytics ID',
description: 'Track this specific listing in analytics',
required: false,
multiline: false,
},
],
}Comparison: blockFieldOptions vs blockFieldExtensions
| Feature | blockFieldOptions | blockFieldExtensions |
|---------|---------------------|------------------------|
| Purpose | Override options for existing select fields | Add new fields to blocks |
| Supports | Select field options only | All field types |
| Use case | Customize dropdown choices | Add new data fields |
| Block types | System and custom blocks | System blocks only |
Use both together for maximum customization:
export default defineConfig({
siteId: 'your-site-id',
// Override options for existing fields
blockFieldOptions: {
'block.embed': {
layout: {
options: [
{ value: 'blog-grid', label: 'Blog Grid' },
{ value: 'team-cards', label: 'Team Cards' },
],
},
},
},
// Add new fields to blocks
blockFieldExtensions: {
'block.body-text': {
fields: [
{
id: 'layout',
type: 'select',
label: 'Layout',
defaultValue: 'default',
required: false,
multiple: false,
options: [
{ value: 'default', label: 'Default' },
{ value: 'wide', label: 'Wide' },
],
},
],
},
},
});Custom Blocks
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.
Quick Start
- Define the block schema in your
riverbank.config.ts:
import { defineConfig } from '@riverbankcms/sdk/config';
export default defineConfig({
siteId: 'your-site-id',
customBlocks: [
{
id: 'custom.team-member',
title: 'Team Member',
titleSource: 'name',
description: 'Display a team member with photo and bio',
category: 'content',
icon: 'User',
tags: ['team', 'about'],
fields: [
{ id: 'name', type: 'text', label: 'Name', required: true, multiline: false },
{ id: 'role', type: 'text', label: 'Role', required: false, multiline: false },
{ id: 'photo', type: 'media', label: 'Photo', required: false, mediaKinds: ['image'] },
{ id: 'bio', type: 'richText', label: 'Bio', required: false },
],
},
],
});- Create your component to render the block:
// components/blocks/TeamMember.tsx
import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
type TeamMemberContent = {
name: string;
role?: string;
photo?: { url: string; alt?: string };
bio?: string;
};
export function TeamMember({ content }: SystemBlockComponentProps<TeamMemberContent>) {
return (
<div className="flex items-start gap-6 p-6">
{content.photo && (
<img
src={content.photo.url}
alt={content.photo.alt || content.name}
className="h-24 w-24 rounded-full object-cover"
/>
)}
<div>
<h3 className="text-xl font-bold">{content.name}</h3>
{content.role && <p className="text-gray-600">{content.role}</p>}
{content.bio && (
<div className="mt-2 prose" dangerouslySetInnerHTML={{ __html: content.bio }} />
)}
</div>
</div>
);
}- Register the component with
blockOverrides:
// app/[...slug]/page.tsx
import { loadPage, Page } from '@riverbankcms/sdk';
import { TeamMember } from '@/components/blocks/TeamMember';
export default async function CMSPage({ params }) {
const pageData = await loadPage({
client,
siteId: process.env.SITE_ID!,
path: `/${params.slug?.join('/') || ''}`,
});
return (
<Page
{...pageData}
blockOverrides={{
'custom.team-member': TeamMember,
}}
/>
);
}Block Definition Schema
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| id | custom.${string} | Yes | Unique block ID, must start with custom. |
| title | string | Yes | Display name in block picker |
| titleSource | string | No | Field ID to use as block title in lists |
| description | string | No | Description shown in block picker |
| category | BlockCategory | Yes | One of: marketing, content, blog, media, layout, interactive |
| icon | string | No | Lucide icon name (e.g., User, CreditCard) |
| tags | string[] | No | Search tags for discoverability |
| fields | FieldDefinition[] | Yes | Field definitions for the block |
Supported Field Types
All field types from the CMS are supported:
Basic Types:
text- Single or multiline textrichText- Rich text editor with formattingnumber- Numeric inputboolean- Toggle/checkbox
Media & Links:
media- Image, video, or file uploadurl- URL input with validationlink- Internal or external link picker (supports page/entry identifiers)
Selection:
select- Dropdown selectionreference- Reference to other content entries
Date/Time:
date- Date pickertime- Time pickerdatetime- Combined date and time
Complex Types:
group- Group related fields togetherrepeater- Repeatable list of itemsmodal- Fields in a modal dialogtabGroup- Tabbed field groups
Examples
Pricing Card:
{
id: 'custom.pricing-card',
title: 'Pricing Card',
titleSource: 'planName',
category: 'marketing',
icon: 'CreditCard',
fields: [
{ id: 'planName', type: 'text', label: 'Plan Name', required: true, multiline: false },
{ id: 'price', type: 'text', label: 'Price', required: false, multiline: false, description: 'e.g., "$29/mo"' },
{ id: 'featured', type: 'boolean', label: 'Featured Plan', required: false },
{
id: 'features',
type: 'repeater',
label: 'Features',
required: false,
itemLabel: 'Feature',
maxItems: 10,
fields: [
{ id: 'text', type: 'text', label: 'Feature', required: true, multiline: false },
{ id: 'included', type: 'boolean', label: 'Included', required: false },
],
},
{ id: 'ctaLabel', type: 'text', label: 'Button Label', required: false, multiline: false },
{ id: 'ctaLink', type: 'link', label: 'Button Link', required: false },
],
}Testimonial:
{
id: 'custom.testimonial',
title: 'Testimonial',
titleSource: 'authorName',
category: 'content',
icon: 'Quote',
fields: [
{ id: 'quote', type: 'richText', label: 'Quote', required: true },
{ id: 'authorName', type: 'text', label: 'Author Name', required: true, multiline: false },
{ id: 'authorTitle', type: 'text', label: 'Author Title', required: false, multiline: false },
{ id: 'authorPhoto', type: 'media', label: 'Author Photo', required: false, mediaKinds: ['image'] },
{ id: 'companyLogo', type: 'media', label: 'Company Logo', required: false, mediaKinds: ['image'] },
{ id: 'rating', type: 'number', label: 'Rating (1-5)', required: false },
],
}Validation Rules
- Maximum 20 custom blocks per site
- Block IDs must be unique within the site
- Block IDs must start with
custom.followed by lowercase letters, numbers, or hyphens titleSourcemust reference a valid field ID if provided- At least one field is required per block
- Maximum 5 data loaders per block
Best Practices
- Use descriptive IDs:
custom.team-memberis better thancustom.tm - Set
titleSource: Helps editors identify blocks in the page structure - Add descriptions: Help editors understand when to use each block
- Group related fields: Use
groupfor related fields that should appear together - Validate required fields: Mark essential fields as
required: true - Provide defaults: Use
defaultValuefor optional fields with sensible defaults
Custom Block Data Loaders
Custom blocks can fetch data automatically using data loaders. There are two approaches:
- Config-based loaders - Declarative loaders defined in
riverbank.config.tsfor whitelisted CMS endpoints - Code-based loaders - Functions passed to
loadPage()for external APIs
Both loader types run server-side during loadPage(). Data is passed to your component via the data prop.
Config-Based Loaders (CMS Endpoints)
Define loaders declaratively in your block definition. These are restricted to whitelisted CMS endpoints for security.
Supported Endpoints:
| Endpoint | Description |
|----------|-------------|
| listPublishedEntries | Fetch published content entries |
| getPublishedEntryPreview | Fetch a single entry by slug |
| listPublicEvents | Fetch events |
| getPublicFormById | Fetch form configuration |
| getPublicBookingServices | Fetch booking services |
Parameter Bindings:
Loader params can be static values or dynamic bindings:
{ $bind: { from: 'content.fieldName' } }- Bind to a block field value{ $bind: { from: 'content.fieldName', fallback: '10' } }- With fallback{ $bind: { from: '$root.siteId' } }- Bind to site ID from page context{ $bind: { from: '$root.pageId' } }- Bind to page ID{ $bind: { from: '$root.previewStage' } }- 'published' or 'preview'
Example: Blog Listing Block
// riverbank.config.ts
import { defineConfig } from '@riverbankcms/sdk/config';
export default defineConfig({
siteId: 'your-site-id',
customBlocks: [
{
id: 'custom.featured-posts',
title: 'Featured Posts',
category: 'blog',
icon: 'FileText',
fields: [
{ id: 'limit', type: 'number', label: 'Number of posts', required: false },
{ id: 'category', type: 'text', label: 'Category filter', required: false, multiline: false },
],
dataLoaders: {
posts: {
endpoint: 'listPublishedEntries',
params: {
siteId: { $bind: { from: '$root.siteId' } },
type: 'blog-post',
limit: { $bind: { from: 'content.limit', fallback: '10' } },
},
},
},
},
],
});// components/blocks/FeaturedPosts.tsx
import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
type FeaturedPostsContent = {
limit?: number;
category?: string;
};
type FeaturedPostsData = {
posts: { entries: Array<{ id: string; title: string; slug: string }> };
};
export function FeaturedPosts({
content,
data,
}: SystemBlockComponentProps<FeaturedPostsContent, FeaturedPostsData>) {
const posts = data?.posts?.entries ?? [];
return (
<div className="grid gap-4">
{posts.map((post) => (
<article key={post.id}>
<a href={`/blog/${post.slug}`}>
<h3>{post.title}</h3>
</a>
</article>
))}
</div>
);
}Code-Based Loaders (External APIs)
For data from external APIs, pass loader functions to loadPage(). This gives you full control over the fetch logic.
Example: E-commerce Product Block
// app/[...slug]/page.tsx
import { loadPage, Page } from '@riverbankcms/sdk';
import { FeaturedProducts } from '@/components/blocks/FeaturedProducts';
export default async function CMSPage({ params }) {
const pageData = await loadPage({
client,
siteId: process.env.SITE_ID!,
path: `/${params.slug?.join('/') || ''}`,
dataLoaderOverrides: {
'custom.featured-products': {
products: async (ctx) => {
// Access block content for parameters
const categoryId = ctx.content.categoryId as string;
const limit = (ctx.content.limit as number) ?? 10;
const res = await fetch(
`https://api.shop.com/products?category=${categoryId}&limit=${limit}`,
{ headers: { 'Authorization': `Bearer ${process.env.SHOP_API_KEY}` } }
);
return res.json();
},
},
},
});
return (
<Page
{...pageData}
blockOverrides={{
'custom.featured-products': FeaturedProducts,
}}
/>
);
}// components/blocks/FeaturedProducts.tsx
import type { SystemBlockComponentProps } from '@riverbankcms/blocks';
type ProductsContent = {
categoryId: string;
limit?: number;
};
type ProductsData = {
products: Array<{ id: string; name: string; price: number; image: string }>;
};
export function FeaturedProducts({
content,
data,
}: SystemBlockComponentProps<ProductsContent, ProductsData>) {
const products = data?.products ?? [];
return (
<div className="grid grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="border rounded p-4">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
}DataLoaderContext
Code-based loaders receive context about the block and page:
interface DataLoaderContext {
/** Site ID from page context */
siteId: string;
/** Page ID from page context */
pageId: string;
/** Unique block instance ID */
blockId: string;
/** Block kind (e.g., 'custom.featured-products') */
blockKind: string;
/** The block's CMS content (field values) */
content: Record<string, unknown>;
/** Whether fetching preview/draft content */
previewStage: 'published' | 'preview';
}Combining Config and Code Loaders
You can use both loader types together. Config loaders run first, then code loaders. On key conflicts, code loaders take precedence.
// riverbank.config.ts - Config loader for CMS data
customBlocks: [{
id: 'custom.product-showcase',
dataLoaders: {
relatedPosts: {
endpoint: 'listPublishedEntries',
params: {
siteId: { $bind: { from: '$root.siteId' } },
type: 'blog-post',
limit: '3',
},
},
},
}]
// page.tsx - Code loader for external API
const pageData = await loadPage({
client,
siteId,
path: '/',
dataLoaderOverrides: {
'custom.product-showcase': {
products: async (ctx) => fetchProductsFromShopify(ctx.content.collectionId),
},
},
});
// Component receives both: data.relatedPosts (CMS) and data.products (Shopify)Error Handling
Data loaders are best-effort. If a loader fails:
- The error is logged to console
- The loader key is undefined in the
dataprop - Other loaders continue executing
- Your component should handle missing data gracefully
function MyBlock({ data }: SystemBlockComponentProps<Content, Data>) {
// Handle missing data
const products = data?.products ?? [];
if (products.length === 0) {
return <p>No products available</p>;
}
return <ProductGrid products={products} />;
}Data Exports
Import data utilities from @riverbankcms/sdk/data:
import {
// Core prefetch function
prefetchBlockData,
// Code loader execution
executeCodeLoaders,
mergeLoaderResults,
// Types
type DataLoaderContext,
type DataLoaderFn,
type BlockLoaderMap,
type DataLoaderOverrides,
type PrefetchContext,
type ResolvedBlockData,
type SdkPrefetchOptions,
} from '@riverbankcms/sdk/data';Content Configuration (SDK Content Scaffolding)
Define content types, pages, entries, navigation menus, and site settings in code. When you push your SDK config, this content is automatically synced to the CMS database.
Quick Start
Create a content.config.ts file alongside your riverbank.config.ts:
// content.config.ts
import { defineContentConfig } from '@riverbankcms/sdk/config';
export const contentConfig = defineContentConfig({
// Content type definitions
contentTypes: [
{
key: 'testimonial',
name: 'Testimonial',
hasPages: false,
fields: [
{ id: 'quote', type: 'richText', label: 'Quote', required: true },
{ id: 'auth