Skip to main content

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">
&ldquo;{quote}&rdquo;
</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: the graphql function 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.graphql type and Block union addition
  • Mapper: add a case in map-fields.ts instead of map-bloks.ts
  • Type definitions: Contentful types are generated from the content model rather than defined in a .types.d.ts file

The frontend component and block renderer registration are identical regardless of CMS.

Further reading

  • Customization for extending the schema with project-specific types