Adding a CMS Content Block Type
Key Concepts
CMS Objects
block
: A content block is a box for any content type that can be rendered inline: Hero Banners, Video, Text Blocks, etc.page
: Page metadata plus content blocks. Will live at a standalone url.navigation
: Structured data model for a site navigation treefooter
Structured data model for a footer.
For this guide, we are talking about block
, and specifically
how to add a new type of block, mediaHero
. After completing these
steps, a merchandiser could add this media hero to any content block
on the site.
1. Configure the Schema in the CMS
Define your new content type in your CMS. Configure the appropriate schemas and fields.
2. Create Content Block Type Definition
In the @hv/cms
package, add a zod
schema definition in src/models/block.ts
.
For example, here is a zod schema definition for a media block hero that has a several text fields and and some CTAs:
export const mediaHeroBlockSchema = baseBlockSchema.merge(
z.object({
id: z.string(),
type: z.literal('mediaHero'),
eyebrowText: optionalField(z.string()),
header: optionalField(z.string()),
subcopy: optionalField(z.string()),
image: z.object({
type: z.literal('image'),
src: z.string(),
alt: z.string(),
}),
ctas: optionalField(
z.array(
z.object({
label: optionalField(z.string()),
href: optionalField(z.string()),
})
)
),
})
);
export type MediaHeroBlock = z.infer<typeof mediaHeroBlockSchema>;
// Add new block schema so zod will parse it when
// it is included in a content block
export const blockSchema: z.ZodType<unknown> = z.union([
mediaHeroBlockSchema,
...
])
// Add it to type alias registry
export type ContentBlockTypes = {
mediaHero: MediaHeroBlock;
...
}
3. Normalize it in the vendor CMS package
In your vendor CMS package, such as contentful
, modify the block
function in normalize.ts
to parse the CMS values to the zod schema:
case 'mediaHero': {
const {
fields: {
ctas = [],
eyebrowText,
header,
image,
subcopy,
},
} = block;
const model: MediaHeroBlock = mediaHeroBlockSchema.parse({
type: 'mediaHero',
id,
ctas: ctas.map((cta: any) => ({
label: cta.fields.label,
href: cta.fields.href,
variant: toButtonVariant(cta.fields.variant),
})),
eyebrowText,
header,
image: {
type: 'image',
src: image.src,
alt: image.alt,
},
subcopy,
});
return model;
}
4. Create the UI
Create a component in your app that extends the CMS data model as its props and outputs a React UI.
This example component composes some components from the ui library to build a ui for a media hero:
// apps/web/app/_components/content/media/media-hero.tsx
import type { MediaHeroBlock } from '@hv/cms/models';
import { Button } from '@hv/ui/button';
import * as UI from '@hv/ui/hero';
export type MediaHeroProps = MediaHeroBlock & {
priority?: boolean;
};
export function MediaHero({
ctas = [],
eyebrowText,
header,
image,
priority,
subcopy,
}: MediaHeroProps) {
const hasCta = ctas.length > 0;
const hasText = eyebrowText || header || subcopy;
return (
<UI.MediaHero {...orientation} textColor={textColor}>
{image && (
<UI.MediaHeroBackground asChild>
<Image src={image.src} alt={image.alt} fill priority={priority} />
</UI.MediaHeroBackground>
)}
{(hasText || hasCta || hasLink) && (
<UI.MediaHeroContent>
{hasText && (
<UI.MediaHeroTextBlock position={orientation?.textAlignment}>
{eyebrowText && (
<UI.MediaHeroEyebrow className='text-xl'>
{eyebrowText}
</UI.MediaHeroEyebrow>
)}
{header && <UI.MediaHeroHeader>{header}</UI.MediaHeroHeader>}
{subcopy && <UI.MediaHeroSubcopy>{subcopy}</UI.MediaHeroSubcopy>}
</UI.MediaHeroTextBlock>
)}
{hasCta && (
<UI.MediaHeroCTAs direction={orientation?.ctaDirection}>
{ctas.map((cta, idx) => (
<Button asChild key={idx} variant={cta.variant}>
<Link href={cta.href ?? ''}>{cta.label}</Link>
</Button>
))}
</UI.MediaHeroCTAs>
)}
</UI.MediaHeroContent>
)}
</UI.MediaHero>
);
}
Finally, In the cms-component-map.tsx
file in the Next.js app,
export your component so the CMS integration uses it to render media heros:
export { MediaHero as mediaHero } from '@/app/_components/content/media/media-hero';