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…

📚 Comprehensive Payload CMS Guides
Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.
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:
- Define the type based on Figma design
- Build the component using shadcn/ui and Lucide
- Create example data for development
- Update BlockRenderer to route to your component
- 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:
blockTypeis always a literal string matching your block name. This is how BlockRenderer knows which block it is.templatelets you have multiple layouts for the same block type (carousel vs grid).- Fields like
title,description, andselectedIndustriescome directly from analyzing the Figma design. bgColoranditemsPerVieware 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/uicomponents (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.
Complete Guide: Use Existing Payload CMS Block Types
Use @payload-types to build Hero, Services and CTA blocks with shadcn/ui + Tailwind for type-safe components
This is the last article in the series