Integration Guide
This guide shows how to bring together Verevoir's packages to build a content editing interface. It covers the core schema/storage/editor integration, then layering on publishing, internal linking, AI-assisted editing, and assets.
Installation
npm install @verevoir/schema @verevoir/storage @verevoir/editor
Peer dependencies: react, react-dom, zod.
Optional packages for additional features:
npm install @verevoir/assets # asset management (images, files)
npm install @verevoir/media # image display, imgproxy URLs, React components
npm install @verevoir/access # auth, policies, workflows
Overview
The packages have a clear dependency chain:
schema ← storage (uses Document type)
schema ← editor (reads FieldDefinition metadata)
schema ← media (image/video block definitions)
storage ← assets (metadata persistence via StorageAdapter)
- Schema defines content shapes and validation
- Storage persists documents using whichever database you choose
- Editor renders editing forms from content model definitions
- Assets manages binary files with metadata
- Media displays images with resizing and hotspot support
- Access handles authentication, authorisation, and workflows
The editor doesn't know about storage, and storage doesn't know about content models. Your application code connects them.
Step 1: Define Your Blocks
// src/blocks/article.ts
import { defineBlock, text, richText, select, boolean, reference } from '@verevoir/schema';
export const article = defineBlock({
name: 'article',
fields: {
title: text('Title').max(120),
author: reference('Author', 'author'),
body: richText('Body')
.hint('Clear, scannable paragraphs. Active voice.'),
status: select('Status', ['draft', 'published', 'archived']),
featured: boolean('Featured').default(false),
},
});
// src/blocks/author.ts
import { defineBlock, text, richText, select } from '@verevoir/schema';
export const author = defineBlock({
name: 'author',
fields: {
name: text('Name').max(100),
email: text('Email').regex(/^[\w.-]+@[\w.-]+\.\w+$/),
bio: richText('Bio')
.hint('Third person, 2-3 sentences. Mention role and expertise.')
.optional(),
role: select('Role', ['author', 'editor', 'admin']),
},
});
// src/blocks/index.ts
import type { BlockDefinition, FieldRecord } from '@verevoir/schema';
import { article } from './article';
import { author } from './author';
export { article, author };
export const blocks: Record<string, BlockDefinition<FieldRecord>> = { article, author };
The .hint() modifier adds a natural-language directive for AI-assisted editing — see the AI-Assisted Editing guide. It has no effect on validation.
Step 2: Set Up Storage
For development, use MemoryAdapter. For production, use PostgresAdapter or your own custom adapter.
// src/storage.ts
import { MemoryAdapter } from '@verevoir/storage';
export const storage = new MemoryAdapter();
For Postgres:
import { PostgresAdapter } from '@verevoir/storage';
export const storage = new PostgresAdapter({
connectionString: process.env.DATABASE_URL,
});
// At startup:
await storage.connect();
await storage.migrate();
Step 3: Build an Editor Component
The editor connects content model definitions to the storage layer through your component code.
// src/components/DocumentEditor.tsx
import { useEffect, useState } from 'react';
import { BlockEditor, useBlockForm, ReferenceOptionsProvider } from '@verevoir/editor';
import type { ReferenceOptionsMap } from '@verevoir/editor';
import { storage } from '../storage';
import { blocks } from '../blocks';
interface Props {
blockType: string;
documentId?: string; // undefined = new document
onSave: () => void;
}
export function DocumentEditor({ blockType, documentId, onSave }: Props) {
const block = blocks[blockType];
const [loaded, setLoaded] = useState(false);
const [state, actions] = useBlockForm(block, {});
const [refOptions, setRefOptions] = useState<ReferenceOptionsMap>({});
// Load reference options for dropdowns
useEffect(() => {
storage.list('author').then((authors) => {
setRefOptions({
author: authors.map((doc) => ({
id: doc.id,
label: (doc.data as Record<string, unknown>).name as string,
})),
});
});
}, []);
// Load existing document data
useEffect(() => {
if (documentId) {
storage.get(documentId).then((doc) => {
if (doc) actions.onChange(doc.data as Record<string, unknown>);
setLoaded(true);
});
} else {
setLoaded(true);
}
}, [documentId]);
const handleSave = async () => {
if (!actions.validate()) return; // populates state.errors
if (documentId) {
await storage.update(documentId, state.value);
} else {
await storage.create(blockType, state.value);
}
onSave();
};
if (!loaded) return null;
return (
<div>
{/* Show validation errors */}
{Object.entries(state.errors).map(([field, msg]) => (
<div key={field} style={{ color: 'red' }}>
<strong>{field}</strong>: {msg}
</div>
))}
{/* Provide reference options via context */}
<ReferenceOptionsProvider options={refOptions}>
<BlockEditor
block={block}
value={state.value}
onChange={actions.onChange}
/>
</ReferenceOptionsProvider>
<button onClick={handleSave} disabled={!state.dirty}>
Save
</button>
</div>
);
}
Step 4: Build a List Component with Querying
// src/components/DocumentList.tsx
import { useEffect, useState } from 'react';
import type { Document } from '@verevoir/storage';
import { storage } from '../storage';
interface Props {
blockType: string;
onEdit: (id: string) => void;
onNew: () => void;
}
export function DocumentList({ blockType, onEdit, onNew }: Props) {
const [docs, setDocs] = useState<Document[]>([]);
useEffect(() => {
// Fetch with sorting — newest first
storage
.list(blockType, { orderBy: { createdAt: 'desc' } })
.then(setDocs);
}, [blockType]);
return (
<div>
<button onClick={onNew}>New {blockType}</button>
{docs.map((doc) => (
<div key={doc.id}>
<span>{(doc.data as Record<string, unknown>).title as string}</span>
<button onClick={() => onEdit(doc.id)}>Edit</button>
</div>
))}
</div>
);
}
Querying Examples
// Filter by data field
const published = await storage.list('article', {
where: { status: 'published' },
});
// Combine filters
const featured = await storage.list('article', {
where: { status: 'published', featured: true },
orderBy: { createdAt: 'desc' },
limit: 5,
});
// Operators for range queries
const recent = await storage.list('article', {
where: { createdAt: { $gt: new Date('2025-01-01') } },
});
// Case-insensitive search
const matching = await storage.list('article', {
where: { title: { $contains: 'nextlake' } },
});
// Pagination
const page2 = await storage.list('article', {
orderBy: { createdAt: 'desc' },
limit: 10,
offset: 10,
});
Step 5: Resolve References
When displaying articles, you may need to show the author's name instead of a UUID. Use getMany() for batch resolution:
const articles = await storage.list('article', {
where: { status: 'published' },
orderBy: { createdAt: 'desc' },
});
// Collect unique author IDs
const authorIds = [
...new Set(
articles
.map((a) => (a.data as Record<string, unknown>).author as string)
.filter(Boolean),
),
];
// Batch fetch — one query instead of N
const authors = await storage.getMany(authorIds);
// Render with resolved names
articles.forEach((article) => {
const authorId = (article.data as Record<string, unknown>).author as string;
const authorDoc = authors.get(authorId);
const authorName = authorDoc
? (authorDoc.data as Record<string, unknown>).name
: 'Unknown';
console.log(`${article.data.title} by ${authorName}`);
});
Publishing and Visibility
The editor provides publishFields() and isLive() for content with time-based visibility windows.
Adding Publish Fields
Spread publishFields() into any block that needs publish status and scheduling:
import { defineBlock, text, richText } from '@verevoir/schema';
import { publishFields } from '@verevoir/editor';
export const page = defineBlock({
name: 'page',
fields: {
title: text('Title'),
slug: text('Slug'),
body: richText('Body'),
...publishFields(),
},
});
This adds three fields:
| Field | Type | Description |
|---|---|---|
status | 'draft' | 'published' | 'archived' | Editorial workflow state (default: 'draft') |
publishFrom | string | undefined | Optional embargo date (ISO string). Null = immediate. |
publishTo | string | undefined | Optional expiry date (ISO string). Null = forever. |
Status and time window are independent axes. A document can be archived regardless of its time window. The window is only consulted when status is 'published'.
Resolving Visibility
Use isLive() on public routes to determine which documents to show:
import { isLive } from '@verevoir/editor';
// In a public route handler
const docs = await storage.list('page', {
where: { slug: requestedSlug, status: 'published' },
});
const livePage = docs.find((d) => isLive(d.data));
A document is live when all three conditions hold:
status === 'published'publishFromis null or in the pastpublishTois null or in the future
Page Versioning
Multiple documents can share the same slug. At most one should have status: 'published' at a time. This enables a versioning model — create a new draft version, edit it, then publish it (archiving the previous version). The public route uses isLive() to resolve which version is currently visible.
Internal Document Linking
LinkSearchProvider enables rich text fields to link to internal documents. The editor provides the UI; the app provides the search function.
import { LinkSearchProvider } from '@verevoir/editor';
import type { LinkSearchResult } from '@verevoir/editor';
async function searchDocuments(query: string): Promise<LinkSearchResult[]> {
const results: LinkSearchResult[] = [];
// Search across multiple block types
const articles = await storage.list('article', {
where: { title: { $contains: query } },
limit: 5,
});
for (const doc of articles) {
const data = doc.data as Record<string, unknown>;
results.push({
id: doc.id,
title: data.title as string,
blockType: 'article',
url: `/articles/${data.slug}`, // consumer controls the URL
});
}
return results;
}
function AdminShell({ children }) {
return (
<LinkSearchProvider search={searchDocuments}>
{children}
</LinkSearchProvider>
);
}
When LinkSearchProvider is present, rich text link editing shows a document search tab alongside the external URL input. The url field on LinkSearchResult controls what URL is inserted — the editor doesn't know about your URL structure.
Without a LinkSearchProvider, only external URL linking is available.
AI-Assisted Copy Generation
CopyAssistProvider enables LLM-powered copy suggestions in rich text fields. The editor provides the UI (suggest button, suggestion panel with accept/regenerate/dismiss); the app provides the generate function and owns the LLM call.
import { CopyAssistProvider } from '@verevoir/editor';
import type { CopyAssistRequest } from '@verevoir/editor';
async function generateCopy(request: CopyAssistRequest): Promise<string> {
// Your LLM call — the app owns the client, prompt, and voice
const response = await fetch('/api/generate', {
method: 'POST',
body: JSON.stringify(request),
});
return response.text();
}
function AdminShell({ children }) {
return (
<CopyAssistProvider generate={generateCopy}>
{children}
</CopyAssistProvider>
);
}
The generate function receives:
| Field | Type | Description |
|---|---|---|
fieldName | string | The field key (e.g. "bio") |
fieldLabel | string | Human-readable label (e.g. "Speaker Bio") |
hint | string | undefined | Per-field directive from .hint() |
currentValue | string | Existing content (markdown), empty if blank |
context | Record<string, unknown> | Sibling field values from the same block |
The context lets the LLM use surrounding information — for example, a speaker's name and company help generate their bio.
When CopyAssistProvider is present, a suggest button appears in rich text toolbars. Without it, the button is hidden and fields work exactly as before.
See the AI-Assisted Editing guide for full implementation details including global voice, prompts, and asset analysis.
Combining Providers
ReferenceOptionsProvider, LinkSearchProvider, and CopyAssistProvider are independent contexts. Nest them in any order:
function AdminShell({ children }) {
return (
<ReferenceOptionsProvider options={refOptions}>
<LinkSearchProvider search={searchDocuments}>
<CopyAssistProvider generate={generateCopy}>
{children}
</CopyAssistProvider>
</LinkSearchProvider>
</ReferenceOptionsProvider>
);
}
Rich text fields show both the link button and the suggest button when both providers are present. Each provider degrades gracefully when absent.
Asset Integration
For image and file management, add @verevoir/assets and @verevoir/media. See the Media guide for full details.
Briefly:
import { AssetManager, MemoryBlobStore } from '@verevoir/assets';
const manager = new AssetManager({ storage, blobStore: new MemoryBlobStore() });
// Upload with automatic dimension extraction
const asset = await manager.upload({
data: imageBuffer,
filename: 'hero.jpg',
contentType: 'image/jpeg',
createdBy: 'user-1',
});
For display, use @verevoir/media with AssetSourceProvider and ImgproxyConfigProvider:
import { AssetSourceProvider, ImgproxyConfigProvider } from '@verevoir/media';
import { createAssetSource } from '@verevoir/media';
const source = createAssetSource({
manager,
blobUrl: (key) => `/api/blobs/${key}`,
});
function App({ children }) {
return (
<AssetSourceProvider source={source}>
<ImgproxyConfigProvider config={{ baseUrl: 'https://imgproxy.example.com' }}>
{children}
</ImgproxyConfigProvider>
</AssetSourceProvider>
);
}
The useBlockForm Hook
useBlockForm manages form state, validation, and dirty tracking:
const [state, actions] = useBlockForm(block, initialData);
State:
state.value— current form datastate.errors— field-path to error message map (empty untilvalidate()called)state.dirty— true if data has changed since initializationstate.valid— true if no errors
Actions:
actions.onChange(data)— update form data, mark dirty, clear errorsactions.validate()— run block validation, populate errors, return booleanactions.reset()— restore initial data, clear errors and dirty flag
ReferenceOptionsProvider
Reference fields render as select dropdowns. The options come from a React context, not from storage directly — keeping the editor storage-ignorant.
import { ReferenceOptionsProvider } from '@verevoir/editor';
<ReferenceOptionsProvider options={{
author: [
{ id: 'uuid-1', label: 'Alice' },
{ id: 'uuid-2', label: 'Bob' },
],
category: [
{ id: 'uuid-3', label: 'News' },
{ id: 'uuid-4', label: 'Tech' },
],
}}>
<BlockEditor block={article} value={data} onChange={setData} />
</ReferenceOptionsProvider>
Keys are block type names (matching targetBlockType in reference() field definitions). Values are arrays of { id, label } objects.
Without a provider, reference fields render an empty select with just a "Select..." placeholder — graceful degradation.
Styling the Editor
BlockEditor renders unstyled HTML with data- attributes (data-block, data-field, etc.). Style these however you like — CSS modules, Tailwind, inline styles, or the optional example CSS that ships with the editor:
// Import example styles (tree-shakes away if not imported)
import '@verevoir/editor/styles/editor-form.css';
Wrap your BlockEditor in a container with data-editor-form:
<div data-editor-form>
<BlockEditor block={article} value={data} onChange={setData} />
</div>
This gives you styled labels, inputs, textareas, selects, and checkboxes with focus states. Copy and adapt the CSS if you need different styling.
Preview Frame
PreviewFrame renders children in a scaled, width-constrained surface with viewport switching and zoom. Useful for previewing content alongside the editor.
import { PreviewFrame } from '@verevoir/editor';
import '@verevoir/editor/styles/preview-frame.css'; // optional example styles
<PreviewFrame defaultViewport="Tablet">
<h1>{data.title}</h1>
<div>{data.body}</div>
</PreviewFrame>
Custom viewports:
<PreviewFrame
viewports={[
{ label: 'Small', width: 320 },
{ label: 'Large', width: 1440 },
]}
defaultViewport="Large"
>
{children}
</PreviewFrame>
Custom Field Components
Override any field's rendering via the overrides prop:
import type { FieldEditorProps } from '@verevoir/editor';
function MyRichTextEditor({ name, field, value, onChange }: FieldEditorProps<string>) {
// Your custom rich text implementation
return <div>...</div>;
}
<BlockEditor
block={article}
value={data}
onChange={setData}
overrides={{
'rich-text': MyRichTextEditor, // Override by UIHint
body: MyRichTextEditor, // Or by field name (higher priority)
}}
/>
Bundler Configuration
The @verevoir/storage package bundles PostgresAdapter (which depends on pg, a Node.js library) in a single entry point. Browser-only environments need workarounds until the package ships separate entry points.
Vite
Use virtual module plugins to replace Node-only modules with browser-safe stubs:
- Storage: Replace
@verevoir/storagewith aMemoryAdapter-only module via abrowserStorage()plugin - Sharp: Replace
sharpwithexport default null;via abrowserSharp()plugin (needed if using@verevoir/assets)
See the React example's vite.config.ts for both plugins.
Next.js (webpack)
Add resolve.fallback entries for Node modules that shouldn't be bundled for the browser:
// next.config.ts
const config = {
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false, dns: false, net: false, tls: false, 'pg-native': false,
};
// Sharp needs an alias to a no-op shim (resolve.fallback alone isn't enough)
config.resolve.alias = {
...config.resolve.alias,
sharp: path.resolve('./src/shims/sharp.js'),
};
return config;
},
};
The sharp shim (src/shims/sharp.js) exports export default null;. Build warnings about @img/sharp-* optional dependencies are harmless.
Further Reading
- Defining Content Models — blocks, fields, validation,
.hint(), and type inference - Building a Storage Adapter — implementing
StorageAdapterfor a new database - Content Controls — polymorphic content blocks for page builders
- Access Control — auth adapters, policies, guards, and workflows
- Media — asset management, imgproxy URLs, and image/video display
- AI-Assisted Editing — copy generation, asset analysis, and LLM integration