Adding a CMS content block
Content blocks are the building blocks of CMS pages. Each block type follows a pipeline: Storyblok component → backend mapper → GraphQL type → React component. This guide walks through adding a new block type (a testimonial quote) end to end.
The same pattern applies to Contentful. The only difference is the mapper
file (map-fields.ts instead of map-bloks.ts).
1. Define the GraphQL type
Add the new type and include it in the Block union in the Storyblok CMS
service schema:
# backend/services/cms-storyblok/schema.graphql
type TestimonialBlock {
id: ID!
quote: String!
author: String!
role: String
image: ContentAsset
}
union Block =
| OneColumnBlock
| TwoColumnsBlock
| ThreeColumnsBlock
| ProductBlock
| CatalogBlock
| TeasersBlock
| TeaserBlock
| UspBlock
| HeroBlock
| TestimonialBlock
2. Add the Storyblok type definition
Define the TypeScript interface for the Storyblok component in
storyblok.types.d.ts. This file is generated by the Storyblok CLI, but you
can add types manually when prototyping:
// backend/services/cms-storyblok/src/storyblok.types.d.ts
interface StoryblokTestimonial {
_uid: string;
component: "testimonial";
quote: string;
author: string;
role?: string;
image?: StoryblokAsset;
}
Add it to the content page body union so the mapper recognizes it:
interface StoryblokContentPage {
// ...
body?: (
| StoryblokButtons
| StoryblokTeaserRow
// ... existing types
| StoryblokTestimonial
)[];
}
3. Create the mapper function
Add a case to the switch in mapBlocks and implement the mapper function in
map-bloks.ts:
// backend/services/cms-storyblok/src/mappers/map-bloks.ts
// In the mapBlocks switch:
case "testimonial":
return mapTestimonialBlock(block, storeContext);
// Mapper function:
const mapTestimonialBlock = (
data: StoryblokTestimonial,
storeContext: StoreContext,
): TestimonialBlock => ({
__typename: "TestimonialBlock",
id: data._uid,
quote: data.quote,
author: data.author,
role: data.role ?? null,
image: data.image?.filename
? { filename: data.image.filename, alt: data.image.alt ?? "" }
: null,
});
The __typename is set explicitly. This is how the frontend's GraphQL client
resolves the union type.
4. Create the React component
Create the frontend component with a co-located GraphQL fragment, following the
pattern of existing blocks like teaser-block.tsx:
// frontend/site/src/components/content-blocks/testimonial-block.tsx
import { cn } from "@evolve-storefront/ui/helpers/styles";
import type { ResultOf } from "@graphql-typed-document-node/core";
import Image from "next/image";
import type { ReactNode } from "react";
import { graphql } from "#generated/gql.ts";
import { cmsPreviewAttributes } from "#lib/cms-preview.ts";
export const TestimonialBlockFragment = graphql(/* GraphQL */ `
fragment TestimonialBlockFragment on TestimonialBlock {
__typename
id
quote
author
role
image {
filename
alt
}
}
`);
type Props = {
className?: string;
data: ResultOf<typeof TestimonialBlockFragment>;
};
export const TestimonialBlock = ({
className,
data,
}: Props): ReactNode => {
const { quote, author, role, image } = data;
return (
<figure
className={cn("flex flex-col items-center gap-4 text-center", className)}
{...cmsPreviewAttributes(data)}
>
{image?.filename && (
<Image
src={image.filename}
alt={image.alt ?? ""}
width={80}
height={80}
className="rounded-full object-cover"
/>
)}
<blockquote className="text-lg italic">
“{quote}”
</blockquote>
<figcaption>
<span className="font-semibold">{author}</span>
{role && <span className="text-gray-500"> — {role}</span>}
</figcaption>
</figure>
);
};
Key patterns:
- Fragment co-location: the fragment lives in the same file as the component, keeping data requirements explicit.
cmsPreviewAttributes: enables Storyblok's visual editor to highlight this block for in-place editing.#generated/gql.ts: thegraphqlfunction from codegen provides type-safe fragment definitions.
5. Register in the block renderer
Open render-block.tsx and wire the new component:
// frontend/site/src/components/content-blocks/render-block.tsx
// 1. Import the component and fragment
import {
TestimonialBlock,
TestimonialBlockFragment,
} from "./testimonial-block.tsx";
// 2. Add the fragment spread to RenderBlockFragment
export const RenderBlockFragment = graphql(/* GraphQL */ `
fragment RenderBlockFragment on Block {
...OneColumnBlockFragment
...TwoColumnBlockFragment
...ThreeColumnsBlockFragment
...ProductBlockFragment
...UspBlockFragment
...TeaserBlockFragment
...TeasersBlockFragment
...HeroBlockFragment
...CatalogBlockFragment
...TestimonialBlockFragment
}
`);
// 3. Add the switch case
case "TestimonialBlock":
return (
<Section>
<Container>
<TestimonialBlock data={block} key={block.id} />
</Container>
</Section>
);
Most blocks are wrapped in <Section><Container> for consistent page layout.
Only full-width blocks like HeroBlock skip the wrapper.
6. Run codegen and verify
After all changes, regenerate the TypeScript types and check for errors:
pnpm codegen
pnpm check
Create a test page in Storyblok with the new testimonial component to verify the full pipeline.
Note on Contentful
The Contentful CMS service follows the same pattern. The differences are:
- Schema: same
schema.graphqltype andBlockunion addition - Mapper: add a case in
map-fields.tsinstead ofmap-bloks.ts - Type definitions: Contentful types are generated from the content model
rather than defined in a
.types.d.tsfile
The frontend component and block renderer registration are identical regardless of CMS.
Further reading
- Customization for extending the schema with project-specific types