Tulip Logo IconTulip
Blocks

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>

On this page