Adding a CMS content block
Content blocks are the building blocks of CMS pages. Each block type follows a
pipeline: CMS content type → mapper → GraphQL type → React component. This
guide walks through adding a project-specific block (a ProjectTeaser) end to
end.
For Contentful, the whole pipeline is extensible from your project: the
Block union is open for extension via a project-owned module, and
ContentfulModule accepts a mappers callback for wiring your content types.
No framework changes are needed. Contributing a block upstream to the
framework packages is the secondary path, covered at the end — as is the
current state of Storyblok, which does not yet have the same extension hooks.
1. Define the block in a project-owned module
Section titled “1. Define the block in a project-owned module”Create a module in the CMS service that owns the new GraphQL type and extends
the framework’s Block union. First add graphql-tag to the service (it is
already in the workspace catalog:):
// backend/services/cms-contentful/package.json — dependencies"graphql-tag": "catalog:"Then define the module:
import type { MapperRegistry } from "@evolve-framework/contentful";import type { Block } from "@evolve-framework/contentful/graphql/types";import { AbstractModule } from "@evolve-framework/core";import type { Entry, EntrySkeletonType } from "contentful";import { gql } from "graphql-tag";
export class ProjectBlocksModule extends AbstractModule { override typedefs = gql` extend union Block = ProjectTeaser
type ProjectTeaser { id: ID! title: String! content: String } `; override resolvers = {};}The extend union is merged into the framework’s union when the modules are
composed (next step). resolvers stays empty: the union is resolved through
the __typename your mapper sets, so a plain type with scalar fields needs no
resolver of its own.
2. Compose it in src/module.ts
Section titled “2. Compose it in src/module.ts”Add the module to the service’s CompositeModule:
import { ContentfulModule } from "@evolve-framework/contentful";import { CompositeModule } from "@evolve-framework/core";import { resolvePublisher } from "@evolve-packages/cloud-adapters";import { config } from "./config.ts";import { ProjectBlocksModule, registerProjectMappers } from "./project-blocks.ts";
export const createModule = (): CompositeModule => { return new CompositeModule([ new ContentfulModule({ config: { spaceId: config.CONTENTFUL_SPACE_ID, environment: config.CONTENTFUL_ENVIRONMENT, accessToken: config.CONTENTFUL_ACCESS_TOKEN, previewToken: config.CONTENTFUL_PREVIEW_TOKEN, managementToken: config.CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN, }, messagePublisher: resolvePublisher(config.INTERNAL_EVENTS_TARGET), mappers: registerProjectMappers, }), new ProjectBlocksModule(), ]);};CompositeModule merges the typedefs of all its modules into one subgraph
schema. The resulting union reads:
union Block = CatalogBlock | HeroBlock | OneColumnBlock | ProductBlock | TeaserBlock | TeasersBlock | ThreeColumnsBlock | TwoColumnsBlock | UspBlock | ProjectTeaser3. Register the content-type mapper
Section titled “3. Register the content-type mapper”The mappers callback passed above receives the MapperRegistry,
pre-populated with the framework defaults. Mappers are keyed by the Contentful
sys.contentType.sys.id; registering an already-known id overrides the
default mapper without throwing.
// backend/services/cms-contentful/src/project-blocks.ts (continued)type ProjectTeaserSkeleton = EntrySkeletonType< { title: string; content?: string }, "projectTeaser">;
export const registerProjectMappers = (registry: MapperRegistry): void => { registry.registerBlock("projectTeaser", (_context, entry) => { const data = entry as Entry< ProjectTeaserSkeleton, "WITHOUT_UNRESOLVABLE_LINKS", string >; return { __typename: "ProjectTeaser", id: data.sys.id, title: data.fields.title, content: data.fields.content, } as unknown as Block; });};Two casts are deliberate here. The registry hands you a generic BaseEntry,
so narrow it to your content type’s skeleton (the framework’s own mappers do
the same). And the framework’s generated Block TypeScript union does not
know about project types, so the return value is cast through unknown — the
GraphQL layer resolves the union via the explicit __typename.
Create the matching projectTeaser content type in Contentful and add it to
the page body field’s validations so editors can place it. You can regenerate
entry types from the content model with pnpm codegen:contentful instead of
hand-writing the skeleton.
4. Regenerate the schema and supergraph
Section titled “4. Regenerate the schema and supergraph”The service’s schema file is generated from the composed module, so the new type flows through automatically:
pnpm --filter @evolve-platform/cms-contentful codegen # schema.generated.graphqlpnpm --filter @evolve-platform/graphql-gateway supergraphCheck backend/services/cms-contentful/schema.generated.graphql: the Block
union should now include ProjectTeaser. The supergraph recomposition makes
the type available to the gateway and, from there, the frontend. A root-level
pnpm codegen runs both plus the frontend types in one go.
5. Create the React component
Section titled “5. Create the React component”Create the frontend component with a co-located GraphQL fragment, following
the pattern of existing blocks like teaser-block.tsx:
import { cn } from "@evolve-storefront/ui/helpers/styles.ts";import type { ResultOf } from "@graphql-typed-document-node/core";import type { ReactNode } from "react";import { graphql } from "#generated/gql.ts";import { cmsPreviewAttributes } from "#lib/cms-preview.ts";
export const ProjectTeaserFragment = graphql(/* GraphQL */ ` fragment ProjectTeaserFragment on ProjectTeaser { __typename id title content }`);
type Props = { className?: string; data: ResultOf<typeof ProjectTeaserFragment>;};
export const ProjectTeaser = ({ className, data }: Props): ReactNode => { const { title, content } = data;
return ( <div className={cn("flex flex-col gap-4", className)} {...cmsPreviewAttributes(data)} > <h2 className="text-lg font-semibold">{title}</h2> {content && <p>{content}</p>} </div> );};Key patterns:
- Fragment co-location: the fragment lives in the same file as the component, keeping data requirements explicit.
cmsPreviewAttributes: enables the CMS’s visual editor to highlight this block for in-place editing.#generated/gql.ts: thegraphqlfunction from codegen provides type-safe fragment definitions.
6. Register in the block renderer
Section titled “6. Register in the block renderer”Open render-block.tsx and wire the new component:
// 1. Import the component and fragmentimport { ProjectTeaser, ProjectTeaserFragment,} from "./project-teaser.tsx";
// 2. Add the fragment spread to RenderBlockFragmentexport const RenderBlockFragment = graphql(/* GraphQL */ ` fragment RenderBlockFragment on Block { ...OneColumnBlockFragment ...TwoColumnBlockFragment ...ThreeColumnsBlockFragment ...ProductBlockFragment ...UspBlockFragment ...TeaserBlockFragment ...TeasersBlockFragment ...HeroBlockFragment ...CatalogBlockFragment ...ProjectTeaserFragment }`);
// 3. Add the switch casecase "ProjectTeaser": return ( <Section> <Container> <ProjectTeaser 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.
7. Run codegen and verify
Section titled “7. Run codegen and verify”After all changes, regenerate the TypeScript types and check for errors:
pnpm codegenpnpm checkCreate a test page in Contentful with the new content type to verify the full pipeline.
Storyblok
Section titled “Storyblok”The Storyblok package does not yet offer the same extension hooks:
StoryblokModule has no mappers option and no registry — block mapping is a
hardcoded switch on block.component in
packages/storyblok/src/mappers/map-bloks.ts. Extending the Block union
through a project-owned module composed alongside StoryblokModule works the
same as for Contentful, but there is currently no way to plug in the mapper
from your project. Until such a hook exists, project-specific Storyblok blocks
go through the framework-contribution route below.
Contributing a block upstream
Section titled “Contributing a block upstream”When a block is generic enough to benefit every Evolve project, move it into
the framework instead of keeping it project-owned: add the type to the shared
schema.graphql of both @evolve-framework/contentful and
@evolve-framework/storyblok (the schema is a shared contract, so the two
stay interchangeable), plus the default mapper in each package. Consuming the
update is a package bump:
- Bump the
@evolve-framework/contentful(and@evolve-framework/storyblok) version in thecatalog:section of yourpnpm-workspace.yaml, then runpnpm install. - Run
pnpm codegen— the CMS service regenerates itsschema.generated.graphqlfrom the module, and the gateway supergraph recomposes from it. - Delete your project-owned typedefs and mapper for the block, keeping the frontend component.
Further reading
Section titled “Further reading”- Adding a new CMS for the full framework-package layout
- Customization for extending the schema with project-specific types

