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.