Loading...
This document provides complete, copy-paste ready examples of real blocks in the system.
A banner with style variants (info, warning, error, success).
blocks/Banner/config.tsimport { lexicalEditor } from "@payloadcms/richtext-lexical";
import type { Block } from "payload";
export const BannerBlock: Block = {
slug: "bannerBlock",
dbName: "banner_block",
interfaceName: "BannerBlockProps",
labels: {
singular: "Banner",
plural: "Banners",
},
admin: {
group: "Content",
},
fields: [
{
name: "style",
type: "select",
defaultValue: "info",
label: "Banner Style",
required: true,
options: [
{ label: "Info", value: "info" },
{ label: "Warning", value: "warning" },
{ label: "Error", value: "error" },
{ label: "Success", value: "success" },
],
admin: {
description: "Choose the visual style for this banner",
},
},
{
name: "content",
type: "richText",
editor: lexicalEditor(),
label: "Banner Content",
required: true,
admin: {
description: "The text content to display in the banner",
},
},
],
};
blocks/Banner/Component.tsximport type { BannerBlockProps } from "@/payload-types";
import RichText from "@/components/RichText";
import { cn } from "@/utils/ui";
export const BannerBlockComponent: React.FC<
BannerBlockProps & {
className?: string;
disableInnerContainer?: boolean;
}
> = ({ className, content, style }) => {
return (
<div className={cn("mx-auto my-8 w-full", className)}>
<div
className={cn("border py-3 px-6 flex items-center rounded", {
"bg-card": style === "info",
"border-error bg-error/30": style === "error",
"border-success bg-success/30": style === "success",
"border-warning bg-warning/30": style === "warning",
})}
>
<RichText
data={content}
enableGutter={false}
enableProse={false}
/>
</div>
</div>
);
};
// In collections/contentTabField.ts or globals/Shop/config.ts
import { BannerBlock } from "@/blocks/Banner/config";
fields: [
{
name: "layout",
type: "blocks",
blocks: [
BannerBlock, // ← Add here
// ...other blocks
],
},
]
import { BannerBlockComponent } from "@/blocks/Banner/Component";
import { BannerBlock } from "@/blocks/Banner/config";
const blockComponents = {
"bannerBlock": BannerBlockComponent, // ← slug as key
// ...
}
A carousel that displays multiple slides with images and text.
blocks/Carousel/config.tsimport type { Block } from "payload";
export const CarouselBlock: Block = {
slug: "carousel",
dbName: "carousel_block",
interfaceName: "CarouselBlockProps",
labels: {
singular: "Carousel",
plural: "Carousels",
},
fields: [
{
name: "title",
type: "text",
label: "Carousel Title",
admin: {
description: "Optional title above the carousel",
},
},
{
name: "slides",
type: "array",
label: "Slides",
required: true,
fields: [
{
name: "image",
type: "upload",
relationTo: "media",
label: "Slide Image",
required: true,
admin: {
description: "The image to display in this slide",
},
},
{
name: "heading",
type: "text",
label: "Slide Heading",
admin: {
description: "Main text for this slide",
},
},
{
name: "subheading",
type: "text",
label: "Slide Subheading",
admin: {
description: "Optional subtitle",
},
},
{
name: "link",
type: "text",
label: "Link URL",
admin: {
description: "Optional link for the slide",
},
},
],
admin: {
description: "Add multiple slides for the carousel",
},
},
{
name: "autoplay",
type: "checkbox",
label: "Autoplay",
defaultValue: true,
admin: {
description: "Automatically rotate slides",
},
},
{
name: "interval",
type: "number",
label: "Autoplay Interval (ms)",
defaultValue: 5000,
min: 1000,
max: 30000,
admin: {
condition: (_, siblingData) => siblingData?.autoplay,
description: "Milliseconds between slides",
},
},
],
};
blocks/Carousel/Component.tsximport type { FC, useState, useEffect } from "react";
import type { CarouselBlockProps } from "@/payload-types";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/utils/ui";
export const CarouselComponent: FC<
CarouselBlockProps & {
disableInnerContainer?: boolean;
}
> = ({ title, slides = [], autoplay = true, interval = 5000, id }) => {
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!autoplay || slides.length <= 1) return;
const timer = setInterval(() => {
setCurrent((prev) => (prev + 1) % slides.length);
}, interval);
return () => clearInterval(timer);
}, [autoplay, interval, slides.length]);
if (!slides.length) {
return <div className="text-center text-gray-500">No slides added</div>;
}
const goToPrevious = () => {
setCurrent((prev) => (prev - 1 + slides.length) % slides.length);
};
const goToNext = () => {
setCurrent((prev) => (prev + 1) % slides.length);
};
const slide = slides[current];
return (
<div
key={`carousel-${id}`}
className="container mx-auto my-8 relative"
>
{title && <h2 className="text-2xl font-bold mb-4">{title}</h2>}
<div className="relative overflow-hidden rounded-lg bg-gray-100">
{/* Slide */}
<div className="relative aspect-video">
{slide.image && (
<img
src={slide.image.url}
alt={slide.heading || "Slide"}
className="w-full h-full object-cover"
/>
)}
{/* Overlay with text */}
<div className="absolute inset-0 bg-black/30 flex flex-col justify-end p-6">
{slide.heading && (
<h3 className="text-2xl font-bold text-white">
{slide.heading}
</h3>
)}
{slide.subheading && (
<p className="text-white/90">{slide.subheading}</p>
)}
</div>
</div>
{/* Navigation buttons */}
{slides.length > 1 && (
<>
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white p-2 rounded-full"
aria-label="Previous slide"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-white/80 hover:bg-white p-2 rounded-full"
aria-label="Next slide"
>
<ChevronRight className="w-6 h-6" />
</button>
{/* Slide indicators */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
{slides.map((_, idx) => (
<button
key={idx}
onClick={() => setCurrent(idx)}
className={cn("w-2 h-2 rounded-full transition", {
"bg-white": idx === current,
"bg-white/50": idx !== current,
})}
aria-label={`Go to slide ${idx + 1}`}
/>
))}
</div>
</>
)}
</div>
</div>
);
};
A pricing table with multiple tiers and conditional features.
blocks/PricingTable/config.tsimport { lexicalEditor } from "@payloadcms/richtext-lexical";
import type { Block } from "payload";
export const PricingTableBlock: Block = {
slug: "pricing-table",
interfaceName: "PricingTableBlockProps",
labels: {
singular: "Pricing Table",
plural: "Pricing Tables",
},
fields: [
{
name: "title",
type: "text",
label: "Section Title",
required: true,
},
{
name: "description",
type: "richText",
editor: lexicalEditor(),
label: "Section Description",
},
{
name: "pricingTiers",
type: "array",
label: "Pricing Tiers",
required: true,
fields: [
{
name: "tierName",
type: "text",
label: "Tier Name",
required: true,
},
{
name: "price",
type: "number",
label: "Price",
required: true,
},
{
name: "currency",
type: "select",
defaultValue: "USD",
options: [
{ label: "USD $", value: "USD" },
{ label: "EUR €", value: "EUR" },
{ label: "GBP £", value: "GBP" },
],
},
{
name: "billingPeriod",
type: "select",
defaultValue: "month",
options: [
{ label: "Per Month", value: "month" },
{ label: "Per Year", value: "year" },
{ label: "One Time", value: "once" },
],
},
{
name: "description",
type: "textarea",
label: "Tier Description",
},
{
name: "isPopular",
type: "checkbox",
label: "Highlight as Popular",
defaultValue: false,
},
{
name: "features",
type: "array",
label: "Features",
fields: [
{
name: "featureName",
type: "text",
label: "Feature Name",
required: true,
},
{
name: "included",
type: "checkbox",
label: "Included in this tier",
defaultValue: true,
},
],
},
{
name: "ctaButton",
type: "group",
label: "CTA Button",
fields: [
{
name: "text",
type: "text",
label: "Button Text",
defaultValue: "Get Started",
},
{
name: "link",
type: "text",
label: "Button Link",
required: true,
},
],
},
],
},
],
};
blocks/PricingTable/Component.tsximport type { FC } from "react";
import type { PricingTableBlockProps } from "@/payload-types";
import RichText from "@/components/RichText";
import { CMSLink } from "@/components/Link";
import { Check, X } from "lucide-react";
import { cn } from "@/utils/ui";
export const PricingTableComponent: FC<PricingTableBlockProps> = ({
title,
description,
pricingTiers = [],
}) => {
if (!pricingTiers.length) {
return <div className="text-center text-gray-500">No pricing tiers</div>;
}
return (
<section className="container mx-auto my-12 px-4">
<div className="text-center mb-12">
{title && <h2 className="text-3xl font-bold mb-4">{title}</h2>}
{description && (
<RichText data={description} enableGutter={false} />
)}
</div>
<div className="grid md:grid-cols-3 gap-6">
{pricingTiers.map((tier, idx) => (
<div
key={`pricing-tier-${idx}`}
className={cn(
"border rounded-lg p-6 flex flex-col",
tier.isPopular
? "border-primary bg-primary/5 shadow-lg scale-105"
: "border-gray-200"
)}
>
{/* Tier header */}
{tier.isPopular && (
<div className="bg-primary text-white px-3 py-1 rounded text-sm font-semibold mb-4 w-fit">
Most Popular
</div>
)}
<h3 className="text-xl font-bold mb-2">{tier.tierName}</h3>
{tier.description && (
<p className="text-gray-600 text-sm mb-4">{tier.description}</p>
)}
{/* Price */}
<div className="mb-6">
<span className="text-3xl font-bold">
{tier.currency === "USD" && "$"}
{tier.currency === "EUR" && "€"}
{tier.currency === "GBP" && "£"}
{tier.price}
</span>
<span className="text-gray-600">
{tier.billingPeriod === "month" && "/month"}
{tier.billingPeriod === "year" && "/year"}
{tier.billingPeriod === "once" && " one time"}
</span>
</div>
{/* CTA Button */}
{tier.ctaButton && (
<CMSLink
{...tier.ctaButton}
className="w-full mb-6"
appearance="solid"
/>
)}
{/* Features */}
{tier.features && tier.features.length > 0 && (
<div className="space-y-3 flex-1">
{tier.features.map((feature, fIdx) => (
<div key={`feature-${fIdx}`} className="flex items-start gap-2">
{feature.included ? (
<Check className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
) : (
<X className="w-5 h-5 text-gray-300 flex-shrink-0 mt-0.5" />
)}
<span
className={feature.included ? "" : "text-gray-400 line-through"}
>
{feature.featureName}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</section>
);
};
A block designed specifically for the shop page.
blocks/ShopBlocks/ShopHero/config.tsimport type { Block } from "payload";
export const ShopHero: Block = {
slug: "shop-hero",
interfaceName: "ShopHeroBlock",
labels: {
singular: "Shop Hero",
plural: "Shop Heroes",
},
fields: [
{
name: "title",
type: "text",
label: "Title",
required: true,
},
{
name: "subtitle",
type: "text",
label: "Subtitle",
},
{
name: "description",
type: "textarea",
label: "Description",
},
{
name: "backgroundImage",
type: "upload",
relationTo: "media",
label: "Background Image",
},
{
name: "showOverlay",
type: "checkbox",
label: "Show Dark Overlay",
defaultValue: true,
},
{
name: "overlayOpacity",
type: "number",
label: "Overlay Opacity (%)",
defaultValue: 40,
min: 0,
max: 100,
admin: {
condition: (_, siblingData) => siblingData?.showOverlay,
},
},
{
name: "textAlignment",
type: "select",
label: "Text Alignment",
defaultValue: "center",
options: [
{ label: "Left", value: "left" },
{ label: "Center", value: "center" },
{ label: "Right", value: "right" },
],
},
],
};
blocks/ShopBlocks/ShopHero/Component.tsximport type { FC } from "react";
import type { ShopHeroBlock } from "@/payload-types";
import { cn } from "@/utils/ui";
export const ShopHeroComponent: FC<ShopHeroBlock> = ({
title,
subtitle,
description,
backgroundImage,
showOverlay,
overlayOpacity,
textAlignment,
id,
}) => {
return (
<div
key={`shop-hero-${id}`}
className="relative w-full min-h-96 flex items-center justify-center overflow-hidden"
>
{/* Background Image */}
{backgroundImage && (
<img
src={backgroundImage.url}
alt={title}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{/* Overlay */}
{showOverlay && (
<div
className="absolute inset-0 bg-black"
style={{ opacity: (overlayOpacity || 40) / 100 }}
/>
)}
{/* Content */}
<div
className={cn("relative z-10 text-center px-4 max-w-2xl", {
"text-left": textAlignment === "left",
"text-center": textAlignment === "center",
"text-right": textAlignment === "right",
})}
>
{subtitle && (
<p className="text-sm font-semibold text-primary mb-2">
{subtitle}
</p>
)}
{title && (
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
{title}
</h1>
)}
{description && (
<p className="text-lg text-gray-200 max-w-xl">
{description}
</p>
)}
</div>
</div>
);
};
Add to collections/contentTabField.ts:
import { PricingTableBlock } from "@/blocks/PricingTable/config";
blocks: [
// ... existing blocks
PricingTableBlock,
]
Add to globals/Shop/config.ts:
import { ShopHero } from "@/blocks/ShopBlocks/ShopHero/config";
fields: [
{
name: "layout",
type: "blocks",
blocks: [ShopHero, /* ... */]
}
]
Add to RenderBlocks.tsx:
import { PricingTableComponent } from "@/blocks/PricingTable/Component";
import { ShopHeroComponent } from "@/blocks/ShopBlocks/ShopHero/Component";
const blockComponents = {
"pricing-table": PricingTableComponent,
"shop-hero": ShopHeroComponent,
// ...
}
Pick one of these patterns or tell me what you need:
I'll scaffold the complete implementation! 🚀