---
title: "Create Custom Block Types in Payload CMS — 5-Step Guide"
slug: "create-custom-block-types-payload-cms"
published: "2025-11-17"
updated: "2025-12-25"
categories:
  - "Payload"
tags:
  - "create custom block Payload CMS"
  - "custom block types"
  - "Payload CMS blocks"
  - "BlockRenderer"
  - "TypeScript interfaces for blocks"
  - "shadcn/ui"
  - "Lucide React"
  - "Tailwind CSS"
  - "example data for blocks"
  - "carousel template"
  - "Payload collection migration"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "typescript"
  - "react"
  - "shadcn/ui"
  - "lucide react"
status: "stable"
llm-purpose: "Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring."
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to TypeScript"
  - "Access to React"
  - "Access to shadcn/ui"
  - "Access to Lucide React"
llm-outputs:
  - "Completed outcome: Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring."
---

**Summary Triples**
- (Create Custom Block Types in Payload CMS — 5-Step Guide, focuses-on, Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring.)
- (Create Custom Block Types in Payload CMS — 5-Step Guide, category, general)

### {GOAL}
Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring.

### {PREREQS}
- Access to Payload CMS
- Access to TypeScript
- Access to React
- Access to shadcn/ui
- Access to Lucide React

### {STEPS}
1. Define the TypeScript block type
2. Build the React component
3. Create example data for testing
4. Update BlockRenderer routing
5. Add to page data and test

<!-- llm:goal="Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring." -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to shadcn/ui" -->
<!-- llm:prereq="Access to Lucide React" -->
<!-- llm:output="Completed outcome: Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring." -->

# Create Custom Block Types in Payload CMS — 5-Step Guide
> Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring.
Matija Žiberna · 2025-11-17

# Creating Custom Block Types

> Part 3 of the [Design to Code](/blog/design-driven-block-systems) series — Following [Use Existing Payload Block Types](/blog/payload-cms-use-existing-block-types)

Building a block that doesn't exist in Payload CMS? This guide walks through the complete process: defining your custom type, building the component, creating example data, and integrating everything.

This is the full five-step process for creating a block from scratch.

## When to Use This Guide

Use this guide when:

- Payload CMS doesn't have this block type defined
- You're building a completely new block (FeaturedIndustries, Testimonials, etc.)
- You want to define the data structure yourself based on Figma design

If Payload already has the block type, use [Adding Blocks from Payload](./02-blocks-existing-payload.md) instead.

## The Process: Five Steps

Building a custom block is a straightforward five-step process:

1. Define the type based on Figma design
2. Build the component using shadcn/ui and Lucide
3. Create example data for development
4. Update BlockRenderer to route to your component
5. Add to page data and test

Let's walk through each.

## Step 1: Define the Type

Look at your Figma design. Identify every field you need. Create a TypeScript interface matching what the design shows.

File: `src/types/blocks/featured-industries.ts`

```typescript
import type { Media } from '@payload-types';
import type { CTA } from '@/types/blocks';

/**
 * FeaturedIndustries Block
 * Displays a grid or carousel of industries
 */
export interface FeaturedIndustriesBlock {
  // Identification
  id?: string;
  blockType: 'featuredIndustries';
  template: 'grid' | 'carousel' | 'default';

  // Content
  tagline?: string;              // "Industries We Serve"
  title: string;                 // Main heading
  description?: string;          // Subtitle

  // Data
  selectedIndustries?: Industry[];  // Array of industry items
  itemsPerView?: number;          // For carousel: how many visible at once

  // Styling
  bgColor?: 'white' | 'light' | 'dark';
  showBorder?: boolean;
}

/**
 * Individual industry item
 */
export interface Industry {
  id: string | number;
  title: string;
  slug?: string;
  description?: string;
  icon?: string;                 // Lucide icon name
  image?: Media;
  cta?: CTA;
}
```

Why this structure:

- `blockType` is always a literal string matching your block name. This is how BlockRenderer knows which block it is.
- `template` lets you have multiple layouts for the same block type (carousel vs grid).
- Fields like `title`, `description`, and `selectedIndustries` come directly from analyzing the Figma design.
- `bgColor` and `itemsPerView` are styling/layout options that appear in the design.
- Optional fields use `?:` to indicate they're not required.

Now export this type from the blocks index:

File: `src/types/blocks/index.ts`

```typescript
export type { FeaturedIndustriesBlock, Industry } from './featured-industries';
```

Done. Your type is defined.

## Step 2: Build the Component

Create the component file and implement it. Use the type to know what data you have access to.

File: `src/components/blocks/featured-industries/featured-industries-template-1.tsx`

```typescript
'use client';

import type { FeaturedIndustriesBlock } from '@/types/blocks';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Zap, Heart, Star, ShoppingBag } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';

interface FeaturedIndustriesTemplate1Props {
  data: FeaturedIndustriesBlock;
}

// Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = {
  Zap,
  Heart,
  Star,
  ShoppingBag,
  // Add more as needed
};

export function FeaturedIndustriesTemplate1({ data }: FeaturedIndustriesTemplate1Props) {
  const {
    tagline,
    title,
    description,
    selectedIndustries = [],
    bgColor = 'white',
  } = data;

  const bgClass = {
    white: 'bg-white',
    light: 'bg-gray-50',
    dark: 'bg-gray-900',
  }[bgColor];

  return (
    <section className={`py-24 ${bgClass}`}>
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        {/* Header */}
        {tagline && (
          <p className="text-sm font-semibold text-blue-600 uppercase tracking-widest mb-2">
            {tagline}
          </p>
        )}
        {title && (
          <h2 className="text-4xl md:text-5xl font-bold mb-4 text-gray-900">
            {title}
          </h2>
        )}
        {description && (
          <p className="text-xl text-gray-600 mb-12 max-w-2xl">
            {description}
          </p>
        )}

        {/* Industries Grid */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
          {selectedIndustries.map((industry) => {
            // Resolve icon name to Lucide component
            const Icon = industry.icon ? iconMap[industry.icon] : null;

            return (
              <Card
                key={industry.id}
                className="hover:shadow-lg transition-shadow duration-300 cursor-pointer"
              >
                <CardHeader>
                  {Icon && (
                    <div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center mb-4">
                      <Icon className="w-6 h-6 text-blue-600" />
                    </div>
                  )}
                  <CardTitle className="text-xl">{industry.title}</CardTitle>
                </CardHeader>
                <CardContent className="space-y-4">
                  {industry.description && (
                    <p className="text-gray-600">{industry.description}</p>
                  )}
                  {industry.cta && (
                    <Button
                      variant="outline"
                      asChild
                      className="w-full"
                    >
                      <a href={industry.cta.href || '#'}>
                        {industry.cta.label || 'Learn More'}
                      </a>
                    </Button>
                  )}
                </CardContent>
              </Card>
            );
          })}
        </div>

        {/* Empty state */}
        {selectedIndustries.length === 0 && (
          <div className="text-center py-12">
            <p className="text-gray-500">No industries selected</p>
          </div>
        )}
      </div>
    </section>
  );
}
```

What's happening:

- Component receives data typed as `FeaturedIndustriesBlock`
- It destructures what it needs and uses sensible defaults
- Uses `shadcn/ui` components (Card, Button) as the base
- Customizes appearance with Tailwind classes
- Maps icon names (strings) to Lucide components using `iconMap`
- Renders an empty state if no industries are provided
- Each industry item is rendered in a Card with title, description, icon, and optional CTA

This component is completely data-driven. It doesn't hardcode anything—everything comes from the type.

## Step 3: Create Example Data

Example data serves as both testing data and documentation. Create a separate file for it.

File: `src/components/blocks/featured-industries/featured-industries.example.ts`

```typescript
import type { FeaturedIndustriesBlock, Industry } from '@/types/blocks';

// Individual industry examples
const retailIndustry: Industry = {
  id: 1,
  title: 'Retail',
  slug: 'retail',
  description: 'Custom signage for retail stores and shopping centers',
  icon: 'ShoppingBag',
  cta: {
    label: 'Explore Retail Solutions',
    href: '/industries/retail',
  },
};

const healthcareIndustry: Industry = {
  id: 2,
  title: 'Healthcare',
  slug: 'healthcare',
  description: 'Professional signage for hospitals and medical facilities',
  icon: 'Heart',
  cta: {
    label: 'Explore Healthcare Solutions',
    href: '/industries/healthcare',
  },
};

const hospitality: Industry = {
  id: 3,
  title: 'Hospitality',
  slug: 'hospitality',
  description: 'Wayfinding and branding signage for hotels and restaurants',
  icon: 'Star',
  cta: {
    label: 'Explore Hospitality Solutions',
    href: '/industries/hospitality',
  },
};

// Complete block example
export const featuredIndustriesExample: FeaturedIndustriesBlock = {
  blockType: 'featuredIndustries',
  template: 'default',
  tagline: 'Industries We Serve',
  title: 'Expertise Across Every Market',
  description: 'We work with industries of all sizes. Here are some of our specialties.',
  selectedIndustries: [retailIndustry, healthcareIndustry, hospitality],
  bgColor: 'light',
  itemsPerView: 3,
};

// Array of industries for later use
export const industriesData: Industry[] = [
  retailIndustry,
  healthcareIndustry,
  hospitality,
];
```

This example data:

- Shows exactly how to structure the type
- Provides sample data for testing
- Documents the expected format
- Can be copied and modified for other uses

## Step 4: Update BlockRenderer

Add a case for your new block type:

**File:** `src/components/block-renderer.tsx`

```typescript
import { FeaturedIndustriesTemplate1 } from '@/components/blocks/featured-industries';

export function BlockRenderer({ block }: BlockRendererProps) {
  // ... existing blocks ...

  // NEW: Handle featured-industries block
  if (block.blockType === 'featuredIndustries') {
    if (block.template === 'default') {
      return <FeaturedIndustriesTemplate1 data={block as any} />;
    }
    // Can add more templates here later
    // if (block.template === 'carousel') {
    //   return <FeaturedIndustriesCarousel data={block as any} />;
    // }
  }

  // Fallback for unknown blocks
  console.warn(`Unknown block: ${block.blockType}/${block.template}`);
  return null;
}
```

The BlockRenderer is a router. It checks the `blockType` and routes to the appropriate component. When you add more template variations later (carousel, list view, etc.), you add more conditions here.

## Step 5: Add to Page Data and Test

Add your block to a page and see it render:

**File:** `src/app/data.ts`

```typescript
import type { Page } from '@payload-types';
import { featuredIndustriesExample } from '@/components/blocks/featured-industries/featured-industries.example';

export const homePageData: Page = {
  id: 'home',
  slug: '/',
  title: 'Home',
  layout: [
    {
      blockType: 'hero',
      // ... hero data ...
    },
    {
      // Add your featured industries block
      ...featuredIndustriesExample,
      // Can override specific fields:
      title: 'Our Specialties',
    } as FeaturedIndustriesBlock,
  ],
};
```

Visit your page. You should see the featured industries section rendering with all three example industries.

## Creating Multiple Templates

Need carousel and grid versions of the same block? Create separate components:

File: `src/components/blocks/featured-industries/featured-industries-carousel.tsx`

```typescript
export function FeaturedIndustriesCarousel({ data }: FeaturedIndustriesTemplate1Props) {
  // Carousel implementation using shadcn/ui Carousel
  // Same type, different layout
}
```

Then add to BlockRenderer:

```typescript
if (block.blockType === 'featuredIndustries') {
  if (block.template === 'default') {
    return <FeaturedIndustriesTemplate1 data={block as any} />;
  }
  if (block.template === 'carousel') {
    return <FeaturedIndustriesCarousel data={block as any} />;
  }
}
```

Both templates use the same type, so they share all the same fields. Templates just choose different ways to render them.

## Customization Checklist

Before considering your block complete:

- Component matches Figma design exactly
- Colors use Tailwind classes or match Figma specs
- Spacing (padding, margins, gaps) matches design
- Icons use Lucide React (never SVG imports)
- Buttons use shadcn/ui Button component
- Container widths and responsive breakpoints match design
- Hover states and transitions feel smooth
- Empty states are handled gracefully
- TypeScript has no errors
- Component renders on page without console errors

## Key Files Created

When you're done, you should have:

```
src/types/blocks/
  └─ featured-industries.ts    ← Your custom type

src/components/blocks/featured-industries/
  ├─ featured-industries-template-1.tsx
  ├─ featured-industries-carousel.tsx (optional)
  └─ featured-industries.example.ts

src/components/
  └─ block-renderer.tsx         ← Updated with your block case
```

## Moving to Payload Later

When you're ready to connect Payload CMS, your custom type becomes the basis for the Payload collection config. Your code doesn't change—just the data source:

```typescript
// Before: example data
import { featuredIndustriesExample } from '@/components/blocks/featured-industries/featured-industries.example';

// After: Payload data
const block = await getBlock('featured-industries', id);
```

Your component, type, and BlockRenderer work exactly the same.

## LLM Response Snippet
```json
{
  "goal": "Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring.",
  "responses": [
    {
      "question": "What does the article \"Create Custom Block Types in Payload CMS — 5-Step Guide\" cover?",
      "answer": "Create custom block types in Payload CMS using TypeScript and shadcn/ui - follow a practical 5-step tutorial with example data and BlockRenderer wiring."
    }
  ]
}
```