Create Custom Block Types in Payload CMS — 5-Step Guide

Step-by-step TypeScript guide to define block types, build shadcn/ui components, map Lucide icons, and integrate with…

·Matija Žiberna·
Create Custom Block Types in Payload CMS — 5-Step Guide

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

Creating Custom Block Types

Part 3 of the Design to Code series — Following Use Existing Payload 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 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

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

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

'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

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

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

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

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

Then add to BlockRenderer:

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:

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

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.