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

·Matija Žiberna·
Complete Guide: Use Existing Payload CMS Block Types

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

Part 2 of the Design to Code series — Following Design-Driven Development

You have a Payload CMS setup with block types already defined: Hero, Services, FeaturedProjects, CTA, and more. When you encounter one of these existing types, don't create a custom type. Use the one Payload already provides and focus on building the component.

This guide shows you the path for existing Payload blocks.

When to Use This Guide

Use this guide when:

  • Payload CMS already has this block type defined
  • You're building a component for Hero, Services, FeaturedProjects, CTA, or other existing types
  • You want to use @payload-types directly

Skip this guide if you're creating a new block type (use Creating Custom Block Types instead).

The Process: Three Steps

Building a component from an existing Payload type is straightforward. Here's the complete flow:

  1. Import the type from Payload
  2. Build the component
  3. Add dummy data to your page

That's it. No type definition needed.

Step 1: Import from @payload-types

First, check what Payload has defined. Open @payload-types (the auto-generated types from your Payload schema):

// From @payload-types
import type { HeroBlock } from '@payload-types';

This is the source of truth. Payload has already defined all the fields this block type should have. Your job is just to implement a component that uses them.

Step 2: Build the Component

Create your component file and implement the block using the Payload type.

File: src/components/blocks/hero/hero-template-1.tsx

'use client';

import type { HeroBlock } from '@payload-types';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';

interface HeroTemplate1Props {
  data: HeroBlock;
}

export function HeroTemplate1({ data }: HeroTemplate1Props) {
  const { title, subtitle, backgroundImage, cta } = data;

  return (
    <div
      className="relative h-screen flex items-center justify-center"
      style={{
        backgroundImage: `url(${backgroundImage?.url})`,
        backgroundSize: 'cover',
        backgroundPosition: 'center',
      }}
    >
      {/* Dark overlay */}
      <div className="absolute inset-0 bg-black/50" />

      {/* Content */}
      <div className="relative z-10 text-center text-white max-w-2xl">
        <h1 className="text-6xl font-bold mb-4">{title}</h1>
        {subtitle && <p className="text-xl mb-8">{subtitle}</p>}
        {cta && (
          <Button asChild className="bg-white text-black hover:bg-gray-100">
            <a href={cta.href}>{cta.label}</a>
          </Button>
        )}
      </div>
    </div>
  );
}

What's happening here:

The component receives data typed as HeroBlock (from Payload). It destructures the fields it needs and renders them. The Payload type tells you exactly what fields are available, so TypeScript will error if you reference a field that doesn't exist. No guessing, no surprises.

Notice we use shadcn/ui components (Button, Card) and Tailwind for styling. We customize the appearance to match Figma through className overrides, not by building custom components.

Step 3: Add to BlockRenderer

Update your block renderer to handle this block type:

File: src/components/block-renderer.tsx

import { HeroTemplate1 } from '@/components/blocks/hero';

export function BlockRenderer({ block }: BlockRendererProps) {
  // Check if it's a hero block
  if (block.blockType === 'hero') {
    if (block.template === 'template-1') {
      return <HeroTemplate1 data={block} />;
    }
    if (block.template === 'template-2') {
      return <HeroTemplate2 data={block} />;
    }
  }

  // ... other block types ...
}

The BlockRenderer acts as a router. It checks the blockType and template fields from Payload and renders the appropriate component. If you need multiple templates for the same block type (template-1, template-2, etc.), add more conditions.

Step 4: Add Dummy Data to Page

Before Payload feeds you data, you need example data for development. Create it directly in your page data file:

File: src/app/data.ts

import type { Page } from '@payload-types';

export const homePageData: Page = {
  id: 'home',
  slug: '/',
  title: 'Home',
  layout: [
    {
      blockType: 'hero',
      template: 'template-1',
      title: 'Design. Build. Illuminate.',
      subtitle: 'Custom signage solutions that define how brands are seen.',
      backgroundImage: {
        url: 'https://example.com/hero-bg.jpg',
        alt: 'Hero background',
      },
      cta: {
        label: 'Explore Our Work',
        href: '/portfolio',
      },
    } as HeroBlock,
  ],
};

Notice the as HeroBlock at the end. This tells TypeScript to treat this object as a HeroBlock. TypeScript will error if you're missing required fields or using wrong field names. This is your safety net before Payload is connected.

The data structure matches exactly what Payload would provide. When you switch to Payload later, you just replace this dummy data with real data from the CMS—no component changes needed.

Using Multiple Templates

Often, a block type has multiple template variations. For example, Hero might have:

  • template-1: Full-screen image background
  • template-2: Split layout (text left, image right)
  • template-3: Text-only minimal design

All three use the same HeroBlock type, but they render differently:

// Same type, different templates
{
  blockType: 'hero',
  template: 'template-1',
  title: '...',
  // ... fields ...
} as HeroBlock

{
  blockType: 'hero',
  template: 'template-2',
  title: '...',
  // ... same fields ...
} as HeroBlock

Create a component for each template:

export function HeroTemplate1({ data }: { data: HeroBlock }) { /* ... */ }
export function HeroTemplate2({ data }: { data: HeroBlock }) { /* ... */ }
export function HeroTemplate3({ data }: { data: HeroBlock }) { /* ... */ }

Then route to them in BlockRenderer:

if (block.blockType === 'hero') {
  if (block.template === 'template-1') return <HeroTemplate1 data={block} />;
  if (block.template === 'template-2') return <HeroTemplate2 data={block} />;
  if (block.template === 'template-3') return <HeroTemplate3 data={block} />;
}

All templates share the same type, so they all have the same fields available. Templates just choose which fields to display and how.

Common Payload Block Types in This Project

Here are the existing Payload block types you'll encounter:

Block TypeUse CaseTemplateExample
heroPage hero sectiontemplate-1, template-2Full-screen banner with CTA
servicesService cardsdefaultGrid of services
featured-projectsProject showcasecarousel, gridProjects with images
ctaCall-to-action sectiondefault"Get started" section

Check your Payload schema to see what's defined.

Customizing to Match Figma

Your component needs to match the Figma design exactly. Use shadcn/ui components as the base and customize with Tailwind:

// If Figma shows:
// - Blue background (#0066ff)
// - Rounded corners (12px)
// - Shadow effect

<Card className="bg-blue-600 rounded-xl shadow-lg p-6">
  {/* content */}
</Card>

// Or with inline styles for custom values:
<div
  className="bg-blue-600 rounded-xl shadow-lg p-6"
  style={{
    borderRadius: '12px',
    boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
  }}
>
  {/* content */}
</div>

Never create custom components for standard UI elements. Use shadcn/ui, customize with className and style overrides. This keeps your codebase consistent and reduces maintenance burden.

Key Differences: Existing vs Custom Types

To help you decide which path to take:

AspectExisting Payload TypeCustom Type
Type DefinitionUse @payload-typesCreate in src/types/blocks/
When to UseBlock already defined in PayloadNew block type you're creating
Type File NeededNoYes
Effort~30 minutes (just component)~1-2 hours (type + component + examples)
File CountComponent + BlockRenderer updateType + Component + Examples + BlockRenderer

Testing Your Implementation

Before moving on, make sure:

  1. Component imports from @payload-types
  2. BlockRenderer has the correct conditions
  3. Dummy data in page file matches the type exactly
  4. Component renders on your page
  5. Styling matches Figma design
  6. All interactive elements (buttons, links) work

Quick test: add the block to src/app/data.ts and visit your page. You should see it rendered immediately.

Moving to Payload CMS Later

When you connect to real Payload CMS data, your code doesn't change:

// Before: example data
import homePageData from '@/app/data';

// After: Payload data
async function HomePage() {
  const homePageData = await getPage('home');
  // ... rest of code unchanged
}

Your component, BlockRenderer, and types all work exactly the same. You're just swapping the data source.

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.