Skip to content

New: AI agent integration via Model Context Protocol (MCP).Learn more

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:

backend/services/cms-contentful/src/project-blocks.ts
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.

Add the module to the service’s CompositeModule:

backend/services/cms-contentful/src/module.ts
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 | ProjectTeaser

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.

The service’s schema file is generated from the composed module, so the new type flows through automatically:

Terminal window
pnpm --filter @evolve-platform/cms-contentful codegen # schema.generated.graphql
pnpm --filter @evolve-platform/graphql-gateway supergraph

Check 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.

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/project-teaser.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: the graphql function from codegen provides type-safe fragment definitions.

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 {
ProjectTeaser,
ProjectTeaserFragment,
} from "./project-teaser.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
...ProjectTeaserFragment
}
`);
// 3. Add the switch case
case "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.

After all changes, regenerate the TypeScript types and check for errors:

Terminal window
pnpm codegen
pnpm check

Create a test page in Contentful with the new content type to verify the full pipeline.

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.

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:

  1. Bump the @evolve-framework/contentful (and @evolve-framework/storyblok) version in the catalog: section of your pnpm-workspace.yaml, then run pnpm install.
  2. Run pnpm codegen — the CMS service regenerates its schema.generated.graphql from the module, and the gateway supergraph recomposes from it.
  3. Delete your project-owned typedefs and mapper for the block, keeping the frontend component.