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.

Reference

Complete block registry

Every block type currently registered in VeloCMS, auto-extracted from src/lib/pagebuilder/types.ts.

Block typeDisplay nameTierAvailable on
containerContainer1All plans
buttonButton1All plans
imageImage1All plans
icon-boxIcon Box1All plans
formForm1All plans
headingHeading1All plans
loop-builderLoop Builder1All plans
accordionAccordion1All plans
galleryGallery1All plans
testimonialTestimonial1All plans
sliderSlider2Pro and above
pricing-tablePricing Table2Pro and above
cta-bannerCta Banner2Pro and above
nav-menuNav Menu2Pro and above
videoVideo2Pro and above
tabsTabs2Pro and above
mapMap2Pro and above
counterCounter2Pro and above
social-iconsSocial Icons2Pro and above
countdownCountdown2Pro and above
shape-dividerShape Divider3Business and Agency
icon-listIcon List3Business and Agency
portfolioPortfolio3Business and Agency
flip-boxFlip Box3Business and Agency
popup-triggerPopup Trigger3Business and Agency
progress-barProgress Bar3Business and Agency
animated-headlineAnimated Headline3Business and Agency
product-gridProduct Grid3Business and Agency
add-to-cartAdd To Cart3Business and Agency
table-of-contentsTable Of Contents3Business and Agency
breadcrumbsBreadcrumbs3Business and Agency
alert-boxAlert Box3Business and Agency
search-barSearch Bar3Business and Agency
lottieLottie3Business and Agency
author-bioAuthor Bio3Business and Agency
hotspotHotspot3Business and Agency
timelineTimeline3Business and Agency
logo-carouselLogo Carousel3Business and Agency
custom-htmlCustom Html3Business and Agency
data-tableData Table3Business and Agency
star-ratingStar Rating3Business and Agency
offcanvas-menuOffcanvas Menu3Business and Agency
feature-listFeature List3Business and Agency
dual-headingDual Heading3Business and Agency
post-navigationPost Navigation3Business and Agency
before-afterBefore After3Business and Agency
business-hoursBusiness Hours3Business and Agency
twitter-feedTwitter Feed3Business and Agency
pie-chartPie Chart3Business and Agency
pdf-viewerPdf Viewer3Business and Agency
course-heroCourse Hero3Business and Agency
course-curriculumCourse Curriculum3Business and Agency
course-certificateCourse Certificate3Business and Agency
bookingBooking3Business and Agency
course-heroCourse Hero3Business and Agency
course-curriculumCourse Curriculum3Business and Agency
course-certificateCourse Certificate3Business and Agency

BlockMeta field reference

FieldTypeDescription
typeBlockType (string literal)Unique key used in the AST JSON. Must be kebab-case.
displayNamestringHuman-readable label shown in the palette search results.
iconstring (Lucide icon name)Icon name from the Lucide library. Loaded dynamically.
categorylayout | content | media | conversion | navigation | data | social | commerce | utility | educationGroups blocks in the palette for filtered browsing.
tier1 | 2 | 3Determines which plan can use the block (see tier table above).
isContainerbooleanTrue if the block holds child blocks (Container, Accordion, Tabs).
isServerBlockbooleanTrue if the block requires a server-side data fetch before render (Post Grid, Nav Menu).

Tier access rules

TierAvailable onBlock countExample blocks
1All plans (Free, Pro, Business, Agency)10Container, Button, Image, Heading, Lead Form, Post Grid, Accordion, Gallery, Testimonial, Icon Box
2Pro and above10Slider, Pricing Table, CTA Banner, Nav Menu, Video, Tabs, Map, Counter, Social Icons, Countdown
3Business and Agency only37Portfolio, Animated Headline, Product Grid, Add to Cart, Timeline, Hotspot, Lottie, Course Hero

Block schema Zod patterns

Block schema — common field 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("/"));
typescript

Server block data fetch pattern

src/lib/pagebuilder/server-data.ts (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
}
typescript

Block AST node shape

Block AST node (PageAST.blocks[id])
// 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
}
typescript