██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

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)

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:

FieldTypeDescription
status'draft' | 'published' | 'archived'Editorial workflow state (default: 'draft')
publishFromstring | undefinedOptional embargo date (ISO string). Null = immediate.
publishTostring | undefinedOptional 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:

  1. status === 'published'
  2. publishFrom is null or in the past
  3. publishTo is 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:

FieldTypeDescription
fieldNamestringThe field key (e.g. "bio")
fieldLabelstringHuman-readable label (e.g. "Speaker Bio")
hintstring | undefinedPer-field directive from .hint()
currentValuestringExisting content (markdown), empty if blank
contextRecord<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:

Actions:

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:

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