Block Authoring
The VeloCMS Page Builder works on a block registry — a flat list of BlockMeta objects that describe every available block type. The palette UI reads from this registry to show searchable block cards. The inspector reads it to find the right prop editor. The renderer reads it to decide whether a block needs server-side data fetching or can render as a pure client component. When you add a custom block, you're adding an entry to this registry and a matching React component.
BlockMeta and BlockType
The core types live in src/lib/pagebuilder/types.ts. Every block in the registry is described by a BlockMeta object:
export interface BlockMeta {
/** The unique string key used in the AST */
type: BlockType;
/** Human-readable name shown in the palette */
displayName: string;
/** Lucide icon name for the palette card */
icon: string;
/** Category for grouping in palette search */
category: "layout" | "content" | "media" | "conversion" | "data" | "utility";
/** Palette tier — affects which plans can use the block */
tier: 1 | 2 | 3;
/** True if the block can contain child blocks */
isContainer: boolean;
/** True if the block requires server-side data (uses fetch in getBlockData) */
isServerBlock: boolean;
}The palette tier system
Tier 1 blocks (the foundation set: Container, Button, Image, Heading, Lead Form, Icon Box, Post Grid, Accordion, Gallery, Testimonial) are available on all plans. Tier 2 covers conversion-focused blocks like sliders and countdown timers — available on Pro and above. Tier 3 is the long-tail set (charts, booking widgets, custom code) — Business and Agency only. When you author a custom block, you pick the tier that matches its capability level.
| Tier | Blocks | Available on |
|---|---|---|
| 1 | Container, Button, Image, Heading, Lead Form, Icon Box, Post Grid, Accordion, Gallery, Testimonial | All plans |
| 2 | Pricing Table, Countdown, Slider, CTA Banner, Social Proof | Pro and above |
| 3 | Charts, Booking Widget, Custom HTML, Video, Audio Player | Business and Agency |
Adding a custom block — step by step
Let's add a Pricing Slider block — a horizontal scrolling row of pricing cards, useful for plans with 4+ tiers. You'll need three things: a registry entry in src/lib/pagebuilder/registry.ts, a Zod schema for the block's props, and a render component.
{
type: "pricing-slider",
displayName: "Pricing Slider",
icon: "SlidersHorizontal",
category: "conversion",
tier: 2,
isContainer: false,
isServerBlock: false,
},Next, define the block's props schema. The schema drives both the inspector UI (what fields appear in the right panel when the block is selected) and runtime validation (Zod parses every block node when loading the AST).
import { z } from "zod";
const PricingCardSchema = z.object({
name: z.string().min(1),
price: z.string(), // e.g. "$9/mo"
description: z.string(),
features: z.array(z.string()),
ctaLabel: z.string().default("Get started"),
ctaHref: z.string().url().optional(),
highlighted: z.boolean().default(false),
});
export const PricingSliderSchema = z.object({
type: z.literal("pricing-slider"),
cards: z.array(PricingCardSchema).min(2).max(8),
heading: z.string().optional(),
subheading: z.string().optional(),
});
export type PricingSliderProps = z.infer<typeof PricingSliderSchema>;Now the render component. It receives the validated block data as props. Tailwind classes, Server Component by default (isServerBlock: false means it renders without a server data fetch):
import { cn } from "@/lib/utils";
import type { PricingSliderProps } from "@/lib/pagebuilder/schemas/pricing-slider";
export function PricingSlider({ cards, heading, subheading }: PricingSliderProps) {
return (
<section className="py-16 px-4">
{heading && (
<h2 className="text-4xl font-extrabold text-center tracking-tighter mb-3">
{heading}
</h2>
)}
{subheading && (
<p className="text-muted-foreground text-center mb-10">{subheading}</p>
)}
<div className="flex gap-6 overflow-x-auto pb-4 snap-x snap-mandatory">
{cards.map((card, i) => (
<div
key={i}
className={cn(
"snap-center shrink-0 w-72 rounded-2xl border p-8",
card.highlighted
? "bg-foreground text-background border-foreground"
: "bg-card border-border/60"
)}
>
<p className="text-sm font-bold uppercase tracking-widest mb-2 opacity-70">
{card.name}
</p>
<p className="text-4xl font-black tracking-tighter mb-4">{card.price}</p>
<p className="text-sm opacity-80 mb-6">{card.description}</p>
<ul className="space-y-2 mb-8 text-sm">
{card.features.map((f, j) => (
<li key={j} className="flex gap-2">
<span>✓</span> {f}
</li>
))}
</ul>
{card.ctaHref && (
<a
href={card.ctaHref}
className={cn(
"block text-center rounded-xl py-3 font-semibold transition-colors",
card.highlighted
? "bg-background text-foreground hover:bg-background/90"
: "bg-foreground text-background hover:bg-foreground/90"
)}
>
{card.ctaLabel}
</a>
)}
</div>
))}
</div>
</section>
);
}Registering the block renderer
The renderer in src/components/pagebuilder/BlockRenderer.tsx uses a switch on block.type to pick the right component. Add a case for your new block type:
import { PricingSlider } from "./blocks/PricingSlider";
import { PricingSliderSchema } from "@/lib/pagebuilder/schemas/pricing-slider";
// Inside the switch(block.type):
case "pricing-slider": {
const parsed = PricingSliderSchema.safeParse(block);
if (!parsed.success) return null;
return <PricingSlider {...parsed.data} />;
}Server blocks and data fetching
If your block needs to fetch data at render time (e.g. a Latest Posts block that pulls recent articles), set isServerBlock: true in the registry entry and implement a getBlockData function. The renderer calls this function server-side and passes the result as blockData prop to your component. The Post Grid block (src/components/pagebuilder/blocks/PostGrid.tsx) is the canonical example of a server block.
Reference
Complete block registry
Every block type currently registered in VeloCMS, auto-extracted from src/lib/pagebuilder/types.ts.
| Block type | Display name | Tier | Available on |
|---|---|---|---|
| container | Container | 1 | All plans |
| button | Button | 1 | All plans |
| image | Image | 1 | All plans |
| icon-box | Icon Box | 1 | All plans |
| form | Form | 1 | All plans |
| heading | Heading | 1 | All plans |
| loop-builder | Loop Builder | 1 | All plans |
| accordion | Accordion | 1 | All plans |
| gallery | Gallery | 1 | All plans |
| testimonial | Testimonial | 1 | All plans |
| slider | Slider | 2 | Pro and above |
| pricing-table | Pricing Table | 2 | Pro and above |
| cta-banner | Cta Banner | 2 | Pro and above |
| nav-menu | Nav Menu | 2 | Pro and above |
| video | Video | 2 | Pro and above |
| tabs | Tabs | 2 | Pro and above |
| map | Map | 2 | Pro and above |
| counter | Counter | 2 | Pro and above |
| social-icons | Social Icons | 2 | Pro and above |
| countdown | Countdown | 2 | Pro and above |
| shape-divider | Shape Divider | 3 | Business and Agency |
| icon-list | Icon List | 3 | Business and Agency |
| portfolio | Portfolio | 3 | Business and Agency |
| flip-box | Flip Box | 3 | Business and Agency |
| popup-trigger | Popup Trigger | 3 | Business and Agency |
| progress-bar | Progress Bar | 3 | Business and Agency |
| animated-headline | Animated Headline | 3 | Business and Agency |
| product-grid | Product Grid | 3 | Business and Agency |
| add-to-cart | Add To Cart | 3 | Business and Agency |
| table-of-contents | Table Of Contents | 3 | Business and Agency |
| breadcrumbs | Breadcrumbs | 3 | Business and Agency |
| alert-box | Alert Box | 3 | Business and Agency |
| search-bar | Search Bar | 3 | Business and Agency |
| lottie | Lottie | 3 | Business and Agency |
| author-bio | Author Bio | 3 | Business and Agency |
| hotspot | Hotspot | 3 | Business and Agency |
| timeline | Timeline | 3 | Business and Agency |
| logo-carousel | Logo Carousel | 3 | Business and Agency |
| custom-html | Custom Html | 3 | Business and Agency |
| data-table | Data Table | 3 | Business and Agency |
| star-rating | Star Rating | 3 | Business and Agency |
| offcanvas-menu | Offcanvas Menu | 3 | Business and Agency |
| feature-list | Feature List | 3 | Business and Agency |
| dual-heading | Dual Heading | 3 | Business and Agency |
| post-navigation | Post Navigation | 3 | Business and Agency |
| before-after | Before After | 3 | Business and Agency |
| business-hours | Business Hours | 3 | Business and Agency |
| twitter-feed | Twitter Feed | 3 | Business and Agency |
| pie-chart | Pie Chart | 3 | Business and Agency |
| pdf-viewer | Pdf Viewer | 3 | Business and Agency |
| course-hero | Course Hero | 3 | Business and Agency |
| course-curriculum | Course Curriculum | 3 | Business and Agency |
| course-certificate | Course Certificate | 3 | Business and Agency |
| booking | Booking | 3 | Business and Agency |
| course-hero | Course Hero | 3 | Business and Agency |
| course-curriculum | Course Curriculum | 3 | Business and Agency |
| course-certificate | Course Certificate | 3 | Business and Agency |
BlockMeta field reference
| Field | Type | Description |
|---|---|---|
| type | BlockType (string literal) | Unique key used in the AST JSON. Must be kebab-case. |
| displayName | string | Human-readable label shown in the palette search results. |
| icon | string (Lucide icon name) | Icon name from the Lucide library. Loaded dynamically. |
| category | layout | content | media | conversion | navigation | data | social | commerce | utility | education | Groups blocks in the palette for filtered browsing. |
| tier | 1 | 2 | 3 | Determines which plan can use the block (see tier table above). |
| isContainer | boolean | True if the block holds child blocks (Container, Accordion, Tabs). |
| isServerBlock | boolean | True if the block requires a server-side data fetch before render (Post Grid, Nav Menu). |
Tier access rules
| Tier | Available on | Block count | Example blocks |
|---|---|---|---|
| 1 | All plans (Free, Pro, Business, Agency) | 10 | Container, Button, Image, Heading, Lead Form, Post Grid, Accordion, Gallery, Testimonial, Icon Box |
| 2 | Pro and above | 10 | Slider, Pricing Table, CTA Banner, Nav Menu, Video, Tabs, Map, Counter, Social Icons, Countdown |
| 3 | Business and Agency only | 37 | Portfolio, Animated Headline, Product Grid, Add to Cart, Timeline, Hotspot, Lottie, Course Hero |
Block schema Zod patterns
import { z } from "zod";
// Common patterns used in block schemas:
// Color token (must reference CSS variable, not raw hex)
const colorToken = z.enum([
"primary", "foreground", "muted", "background", "card",
"destructive", "accent"
]);
// Spacing scale (matches Tailwind's 4px grid)
const spacingScale = z.union([
z.literal(0), z.literal(4), z.literal(8), z.literal(12),
z.literal(16), z.literal(24), z.literal(32), z.literal(48),
z.literal(64), z.literal(96), z.literal(128),
]);
// Alignment
const alignment = z.enum(["left", "center", "right"]);
// Button variant
const buttonVariant = z.enum(["primary", "secondary", "ghost", "outline", "link"]);
// Image source (R2 URL or external HTTPS)
const imageSrc = z.string().url().or(z.string().startsWith("/"));Server block data fetch pattern
// Server blocks declare a getBlockData function alongside the schema.
// The renderer calls this before rendering and passes result as blockData prop.
export async function getBlockData(
block: YourBlockSchema,
context: { tenantId: string; siteUrl: string }
): Promise<YourBlockServerData> {
const db = await getTenantDB(context.tenantId);
const posts = await db.getCollection("posts").getList(1, block.limit ?? 6, {
filter: 'status = "published"',
sort: "-published_at",
});
return { posts: posts.items };
}
// In your component:
export function YourServerBlock({
// ...block schema props
blockData, // YourBlockServerData | null — null if fetch failed
}: YourBlockSchema & { blockData: YourBlockServerData | null }) {
if (!blockData) return null; // graceful fallback
// render with blockData
}Block AST node shape
// Every block node in the AST follows this shape:
interface Block {
id: string; // nanoid — unique per page
type: BlockType; // matches registry entry
props: unknown; // validated by block's Zod schema at render time
childrenIds: string[];// empty for non-containers
parentId: string | null;
locked: boolean; // true = cannot be moved/deleted in editor
hidden: boolean; // true = skip render (draft element)
}
// Example post grid node:
{
id: "blk_4xKm9",
type: "loop-builder",
props: {
type: "loop-builder",
limit: 6,
columns: 3,
showExcerpt: true,
showTags: true,
cardStyle: "default"
},
childrenIds: [],
parentId: "blk_root",
locked: false,
hidden: false
}