Getting Started
Define a registry, render stored blocks, and add create/edit/reorder flows.
This guide shows the minimum shape for a block-based feature.
1. Define Blocks
Each block is a plain object with a stable slug, display metadata, and a Zod
schema for its data payload.
import type { BlockDefinition } from "@tulip-systems/blocks";
import { z } from "zod";
export const heroBlock = {
slug: "hero",
label: "Hero",
description: "Title and introduction section.",
schema: z.object({
subTitle: z.string().optional(),
titlePart1: z.string().min(1),
titlePart2: z.string().optional(),
description: z.string().optional(),
}),
} as const satisfies BlockDefinition;Prefer as const satisfies BlockDefinition. It preserves the literal slug and
keeps the schema as the source of truth for inferred types.
2. Create a Registry
The registry turns a non-empty list of definitions into typed schemas and helpers.
import { createBlockRegistry, type InferBlock, type InferBlockInput } from "@tulip-systems/blocks";
import { heroBlock } from "./hero/config";
import { summaryBlock } from "./summary/config";
export const pageBlocks = createBlockRegistry([heroBlock, summaryBlock]);
export type PageBlock = InferBlock<typeof pageBlocks>;
export type PageBlockInput = InferBlockInput<typeof pageBlocks>;Use PageBlock for rows that already have id, slug, index, and data.
Use PageBlockInput for create mutations where storage-owned fields do not exist yet.
3. Render Stored Blocks
Create a component map keyed by block slug.
import type { InferBlockComponents } from "@tulip-systems/blocks";
import { RenderBlocks } from "@tulip-systems/blocks/client";
import { HeroPreview } from "./hero/preview";
import { SummaryPreview } from "./summary/preview";
import { pageBlocks } from "./registry";
const previewComponents = {
hero: HeroPreview,
summary: SummaryPreview,
} satisfies InferBlockComponents<typeof pageBlocks>;
export function PagePreview({ blocks }: { blocks: PageBlock[] }) {
return <RenderBlocks data={blocks} components={previewComponents} />;
}RenderBlocks only dispatches by slug. The app owns the actual component UI.
4. Render a Create Catalog
Create components receive a shared variables mapper and mutation options.
import type { InferBlockCreateComponents } from "@tulip-systems/blocks";
import { RenderCreateBlocks } from "@tulip-systems/blocks/client";
const createComponents = {
hero: HeroCreateBlock,
summary: SummaryCreateBlock,
} satisfies InferBlockCreateComponents<typeof pageBlocks>;
<RenderCreateBlocks
registry={pageBlocks}
components={createComponents}
variables={(block) => ({ pageId, block })}
mutation={createBlockMutation}
/>;The registry provides ordering because create components do not have stored block data yet.
5. Add Inline Editing and Ordering
Use SortableBlocks around RenderBlocks and InlineBlock* inside each inline
component.
<SortableBlocks
data={blocks}
queryKey={blocksQueryOptions.queryKey}
variables={({ activeBlock, toIndex }) => ({
pageId,
id: activeBlock.id,
toIndex,
})}
mutation={moveBlockMutation}
>
<RenderBlocks data={blocks} components={inlineComponents} />
</SortableBlocks>Inside each inline component, wrap the block shell with InlineBlockSortable:
<InlineBlockSortable id={block.id}>
<InlineBlock>
<InlineBlockHeader>
<InlineBlockDragHandle>...</InlineBlockDragHandle>
<InlineBlockTrigger>Hero</InlineBlockTrigger>
</InlineBlockHeader>
<InlineBlockContent>...</InlineBlockContent>
</InlineBlock>
</InlineBlockSortable>