Add a content block
A content block is one content type from end to end: the fields an editor
fills in, the GraphQL type the API returns, and the mapper between the two.
You define all of it once with defineContentBlock and add it to the CMS
module’s blocks. The module merges the block’s GraphQL type into the schema
and registers its mapper.
This page adds a quote block to page bodies. It assumes a CMS module is already in your service. See Add a CMS.
1. Define the block
Section titled “1. Define the block”import { defineContentBlock } from "@evolve-framework/contentful";import type { Block } from "@evolve-framework/contentful/graphql/types";import type { EntryFieldTypes, EntrySkeletonType } from "contentful";
type QuoteSkeleton = EntrySkeletonType< { quote: EntryFieldTypes.Text; author?: EntryFieldTypes.Symbol }, "quote">;
export const quote = defineContentBlock<QuoteSkeleton>({ id: "quote", typename: "QuoteBlock", groups: ["block"], typeDefs: /* GraphQL */ ` type QuoteBlock { id: ID! quote: String! author: String }
union Block = QuoteBlock `, terraform: /* hcl */ ` resource "contentful_contenttype" "quote" { space_id = local.contentful_space_id environment = local.contentful_environment id = "quote" name = "Quote" display_field = "author"
fields = [ { id = "quote", name = "Quote", type = "Text", required = true, localized = true }, { id = "author", name = "Author", type = "Symbol", required = false }, ] } `, // The package's Block type lists only the built-in members, so a new member needs the cast. mapper: (_context, entry) => ({ __typename: "QuoteBlock", id: entry.sys.id, quote: entry.fields.quote, author: entry.fields.author, }) as unknown as Block,});The type parameter is the entry skeleton, so entry.fields is checked against
your content type. The skeleton types come from the contentful package.
import { defineContentBlock, type StoryblokBlok } from "@evolve-framework/storyblok";
type StoryblokQuote = StoryblokBlok & { quote: string; author?: string };type QuoteBlock = { __typename: "QuoteBlock"; id: string; quote: string; author?: string };
export const quote = defineContentBlock({ id: "quote", typename: "QuoteBlock", groups: ["block"], typeDefs: /* GraphQL */ ` type QuoteBlock { id: ID! quote: String! author: String }
union Block = QuoteBlock `, terraform: /* hcl */ ` resource "storyblok_component" "quote" { name = "quote" space_id = local.storyblok_space_id is_root = false is_nestable = true component_group_uuid = local.storyblok_component_groups["content"]
schema = { quote = { position = 0, type = "textarea", display_name = "Quote", required = true, translatable = true } author = { position = 1, type = "text", display_name = "Author" } } } `, mapper: (_storeContext, blok: StoryblokQuote): QuoteBlock => ({ __typename: "QuoteBlock", id: blok._uid, quote: blok.quote, author: blok.author, }),});Both type parameters are inferred from the mapper: annotate blok with the
component’s type and the return with the GraphQL type.
import { defineContentBlock, type PayloadBlock } from "@evolve-framework/payload";
type PayloadQuote = PayloadBlock & { quote: string; author?: string };type QuoteBlock = { __typename: "QuoteBlock"; id: string; quote: string; author?: string };
export const quote = defineContentBlock({ id: "quote", typename: "QuoteBlock", groups: ["block"], typeDefs: /* GraphQL */ ` type QuoteBlock { id: ID! quote: String! author: String }
union Block = QuoteBlock `, payload: { slug: "quote", fields: [ { name: "quote", type: "textarea", required: true }, { name: "author", type: "text" }, ], }, mapper: (_context, block: PayloadQuote): QuoteBlock => ({ __typename: "QuoteBlock", id: block.id ?? "", quote: block.quote, author: block.author, }),});Both type parameters are inferred from the mapper: annotate block with the
block’s type and the return with the GraphQL type. payload is a Payload
block config.
idis what the CMS calls the content type: the Contentful content type id, the Storyblok component name, or the Payload block slug. The mapper registry dispatches on it.typeDefsdeclares the GraphQL type and the union it joins. JoinBlockfor a page body,ColumnEntryfor a column. Merging only adds: a block cannot change or remove a built-in type, and a field that conflicts with the shared definition throws.groupssays where an editor may place the block:blockfor a page body,columnEntryfor a column. The Terraform whitelists and the Payload slots are built from it.- The mapper turns one CMS entry into the GraphQL type. Its
__typenamemust matchtypename.
groups and the union are separate statements. Usually they match, as here.
Blocks that contain blocks
Section titled “Blocks that contain blocks”If your block holds other blocks, declare them in slots, by group or by id,
and map each child through the registry rather than by importing its block.
In Storyblok, call getMapperRegistry().mapValue<T>(storeContext, child). In
Payload, call context.mapValue<T>(child) with the context the mapper
receives.
2. Add it to the module
Section titled “2. Add it to the module”blocks is the complete set the module serves, so spread the built-ins and
add yours:
import { ContentfulModule, contentBlocks } from "@evolve-framework/contentful";import { quote } from "./blocks/quote.ts";
new ContentfulModule({ // ...config and messagePublisher blocks: [...contentBlocks, quote],});import { contentBlocks, StoryblokModule } from "@evolve-framework/storyblok";import { quote } from "./blocks/quote.ts";
new StoryblokModule({ // ...config, siteConfigs and messagePublisher blocks: [...contentBlocks, quote],});import { contentBlocks } from "@evolve-framework/payload";import { quote } from "./quote.ts";
export const blocks = [...contentBlocks, quote];Pass the same array to both sides: evolveCms({ blocks }) in your Payload
config, and new PayloadModule({ config, blocks }) in your service.
A block with the same id as a built-in replaces the built-in.
How the module registers the mapper depends on the CMS:
- Contentful registers it by
groupsonly:blockwithregisterBlock,columnEntrywithregisterColumnEntry. A block in neither group is not mapped. - Storyblok and Payload register every mapper with
registerValue, so a container can map it as a child, and also bygroups. They wrap a column entry placed directly in a page body in aOneColumnBlock.
An entry with no registered mapper is logged and left out of the response.
3. Create the content type in the CMS
Section titled “3. Create the content type in the CMS”evolve tf-export writes the built-in blocks only. Write your set with
writeTerraform, then
apply it with Terraform:
import { contentBlocks, writeTerraform } from "@evolve-framework/contentful";import { quote } from "./blocks/quote.ts";
writeTerraform([...contentBlocks, quote], "terraform/contentful");Because quote is in the block group, it is added to the
block_content_types whitelist, so editors can add it to a page body.
evolve tf-export writes the built-in blocks only. Write your set with
writeTerraform, then
apply it with Terraform:
import { contentBlocks, writeTerraform } from "@evolve-framework/storyblok";import { quote } from "./blocks/quote.ts";
writeTerraform([...contentBlocks, quote], "terraform/storyblok");Because quote is in the block group, it is added to the
block_components whitelist, so editors can add it to a page body.
evolveCms({ blocks }) adds the block config to your Payload config, and
makes it available in every slot that accepts its group.
4. Render it in the storefront
Section titled “4. Render it in the storefront”The storefront renders blocks with
@evolve-storefront/feature-content-blocks.
It looks up each block’s component by its GraphQL type name.
Select your type in the page query, next to the package’s fragment:
query GetContentPage($path: String!) { contentPage(path: $path) { body { ...ContentBlocksBlockFields ... on QuoteBlock { id quote author } } }}Then register a component under the type name:
import { createRegistry } from "@evolve-storefront/feature-content-blocks";import { defaultBlocks } from "@evolve-storefront/feature-content-blocks/content-blocks";
function Quote({ data }: { data: { quote: string; author?: string | null } }) { return ( <blockquote> <p>{data.quote}</p> {data.author ? <cite>{data.author}</cite> : null} </blockquote> );}
export const registry = createRegistry({ ...defaultBlocks, QuoteBlock: Quote });Change a built-in block
Section titled “Change a built-in block”To replace only the mapper of a built-in block, pass mappers. The callback
receives the registry with every block already registered. A registration for
a known id replaces the existing mapper.
new StoryblokModule({ // ...config, siteConfigs and messagePublisher mappers: (registry) => { registry.registerBlock("content_teaser", myTeaserMapper); },});Register on the channel the module reads the block from:
| Method | Contentful | Storyblok | Payload |
|---|---|---|---|
registerBlock(id, mapper) |
Page body entries, Block |
Page body bloks, Block |
Page body blocks, Block |
registerColumnEntry(id, mapper) |
Column entries, ColumnEntry |
Column entries, ColumnEntry |
Column entries, ColumnEntry |
registerValue(id, mapper) |
None | Any child blok, by component name | Any child block, by slug |
registerLink(id, mapper) |
Entries in link fields, ResourceLink |
None | None |
In Storyblok and Payload a container, and a page’s hero field, map their
children through registerValue. To change a block everywhere it appears,
register its mapper there as well. Contentful maps a content page’s hero
field with the built-in mapper, whatever you register.
To change more than the mapper, such as the fields or the Terraform, eject the block. See Take ownership of a built-in block.
To add a field to a built-in GraphQL type without replacing the block, add a
module of your own with extend type and a resolver, and compose it next to
the CMS module. See Add your own module.

