Block Authoring

11 min readUpdated 27 Apr 2026

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:

src/lib/pagebuilder/types.ts (excerpt)
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;
}
typescript

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.

TierBlocksAvailable on
1Container, Button, Image, Heading, Lead Form, Icon Box, Post Grid, Accordion, Gallery, TestimonialAll plans
2Pricing Table, Countdown, Slider, CTA Banner, Social ProofPro and above
3Charts, Booking Widget, Custom HTML, Video, Audio PlayerBusiness 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.

src/lib/pagebuilder/registry.ts (add this entry)
{
  type: "pricing-slider",
  displayName: "Pricing Slider",
  icon: "SlidersHorizontal",
  category: "conversion",
  tier: 2,
  isContainer: false,
  isServerBlock: false,
},
typescript

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).

src/lib/pagebuilder/schemas/pricing-slider.ts
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>;
typescript

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):

src/components/pagebuilder/blocks/PricingSlider.tsx
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>
  );
}
typescript

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:

src/components/pagebuilder/BlockRenderer.tsx (add case)
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} />;
}
typescript

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.