Adding a new CMS
Evolve’s CMS layer is designed as a pluggable adapter: every CMS integration implements the same GraphQL schema, so the frontend and gateway don’t need to change when you swap or add a CMS. This guide walks through building a new CMS integration from scratch, using Sanity as the example. (No Sanity implementation ships today — Contentful and Storyblok are the two existing integrations.)
How the CMS layer works
Section titled “How the CMS layer works”All CMS logic lives in the framework repository: @evolve-framework/contentful
and @evolve-framework/storyblok each own the shared schema.graphql, the
mappers, resolvers, loaders, webhook handler, and a Module class that wires it
all together. The schema defines page types (ContentPage, CatalogPage), a
Block union for content blocks, site layout types (SiteHeader,
SiteFooter, NavigationMenu), and federation extensions for product
breadcrumbs.
The platform side is a thin mount: backend/services/cms-storyblok and
backend/services/cms-contentful contain only config.ts, module.ts,
server.ts, and index.ts. The package acts as a translation layer:
Sanity API → mappers → shared GraphQL types → frontend componentsThe frontend never sees CMS-specific data. It queries the same page,
siteLayout, and contentSnippet fields regardless of which CMS package is
behind the gateway.
Adding a CMS therefore means three things: a new
@evolve-framework/<cms> package exposing a Module, a thin platform service
mounting it, and the cms supergraph slot pointing at its schema.
1. Scaffold the framework package
Section titled “1. Scaffold the framework package”Create the package in the framework repository, mirroring
packages/storyblok:
Directorypackages/sanity/
Directorysrc/
- index.ts Public exports (module, webhook, utils)
Directorymodules/
- sanity.ts SanityModule (AbstractModule)
- webhook.ts Webhook handler factory
Directorygraphql/
- typedefs.ts Generated from schema.graphql
- types.ts Generated resolver/entity types
- context.ts ContextValue + initContextValue
Directorylib/
- clients.ts Sanity client setup (module-scope singletons)
- cache.ts
Directoryresolvers/
- index.ts
Directoryquery/ page, pages, site-layout, content-snippet
- …
Directorysite-layout/
- …
Directorymutations/
- store-content-preview.ts
Directorymappers/
- pages.ts Sanity document → ContentPage/CatalogPage
- map-blocks.ts Block array mapper
- map-asset.ts Sanity image → ContentAsset
- map-resource-link.ts
Directoryloaders/
- snippets-by-slug-loader.ts
Directoryevents/
Directoryinternal/
- handler.ts
Directorytesting/ Mocks, test server, context factory
- …
- codegen.ts
- schema.graphql Copied from packages/storyblok (shared contract)
- package.json
The schema.graphql is copied verbatim from packages/storyblok/schema.graphql
(or packages/contentful/schema.graphql — they define the same contract).
This is what makes CMS integrations interchangeable.
2. Generate typedefs and types
Section titled “2. Generate typedefs and types”The package’s codegen.ts merges the local schema with the shared
storeContextSnippet and errorsSnippet from @evolve-framework/schemas,
then generates src/graphql/typedefs.ts (via
@labdigital/graphql-codegen-typedefs) and src/graphql/types.ts (resolver
types). Copy packages/storyblok/codegen.ts as a starting point and run
pnpm codegen in the package.
For the RichText block type, the schema has a renderer enum that currently
supports contentful and storyblok. Add sanity to it in your copy of the
schema:
enum RichTextRenderer { contentful storyblok sanity}Serialize Sanity’s Portable Text as JSON, just as Storyblok serializes its
rich text, and set renderer: "sanity" so the frontend picks the right
renderer component.
3. Configure the Sanity clients
Section titled “3. Configure the Sanity clients”Follow the pattern in packages/storyblok/src/lib/clients.ts: a config type,
a create<Cms>Clients factory, and module-scope singleton accessors that the
Module populates in init():
import { createClient, type SanityClient } from "@sanity/client";
export type SanityConfig = { projectId: string; dataset: string; /** Read token for published content. */ apiToken: string; /** Token that can read draft content. */ previewToken: string; apiVersion?: string;};
export type SanityClients = { client: SanityClient; previewClient: SanityClient;};
export const createSanityClients = (config: SanityConfig): SanityClients => { const shared = { projectId: config.projectId, dataset: config.dataset, apiVersion: config.apiVersion ?? "2025-01-01", }; return { client: createClient({ ...shared, token: config.apiToken, useCdn: true }), previewClient: createClient({ ...shared, token: config.previewToken, useCdn: false, perspective: "drafts", }), };};
let clients: SanityClients | undefined;
export const setSanityClients = (value: SanityClients): void => { clients = value;};
export const getSanityClients = (): SanityClients => { if (!clients) { throw new Error("Sanity clients not configured. Construct a SanityModule first."); } return clients;};Two clients: a CDN-backed client for production reads and a preview client that returns draft content. This mirrors the Storyblok access/management client split and the Contentful delivery/preview client split.
4. Create the mappers
Section titled “4. Create the mappers”This is the core of the CMS adapter. Sanity documents use _type as the
discriminator (vs. Storyblok’s component field and Contentful’s
sys.contentType). The block mapper dispatches on _type and produces
__typename-tagged objects matching the shared GraphQL schema — see
packages/storyblok/src/mappers/map-bloks.ts for the reference switch. Each
mapper function sets __typename explicitly; this is how the frontend
resolves the Block union:
const mapTeaserBlock = ( data: SanityContentTeaser, storeContext: StoreContext,): TeaserBlock => ({ __typename: "TeaserBlock", id: data._key, title: data.title, content: data.content, link: mapResourceLink(data.link), image: data.image ? mapSanityImage(data.image) : undefined, imagePosition: data.imagePosition ?? "left",});Note that Sanity uses _key for array items (vs. Storyblok’s _uid).
5. Implement the resolvers
Section titled “5. Implement the resolvers”Implement the same resolver surface both existing packages provide — compare
packages/storyblok/src/resolvers/index.ts:
import type { Resolvers } from "#src/graphql/types.ts";
export const resolvers: Resolvers = { Mutation: { storeContentPreview: storeContentPreviewMutation, }, Query: { page: pageResolver(undefined), pages: pagesResolver, catalogPage: pageResolver("catalogPage"), contentPage: pageResolver("contentPage"), siteLayout: siteLayoutResolver, contentSnippet: contentSnippetResolver, }, SiteLayout: siteLayoutFieldResolvers,};The query resolvers use GROQ to fetch documents and hand them to the mappers.
Use DataLoaders (see packages/storyblok/src/loaders/) for anything fetched
per-field.
6. Write the Module
Section titled “6. Write the Module”The Module is what the platform service mounts. It extends AbstractModule
from @evolve-framework/core: the generated typedefs and the resolvers
map are assigned as fields (which makes getGraphQLConfig() and
getGraphQLSchema() work), init() builds the clients, and
getHttpConfig() registers the webhook route:
import { AbstractModule, type HttpConfig } from "@evolve-framework/core";import type { MessagePublisher } from "@evolve-framework/core/messaging";import { typeDefs } from "#src/graphql/typedefs.ts";import { createSanityClients, type SanityConfig, setSanityClients,} from "#src/lib/clients.ts";import { resolvers } from "#src/resolvers/index.ts";import { createWebhookHandler } from "#src/webhook.ts";
export type SanityModuleOptions = { config: SanityConfig; /** Publisher used to emit `content-modified` events from the webhook. */ messagePublisher: MessagePublisher; /** URL path the webhook is registered on. Defaults to "/api/webhook". */ webhookPath?: string;};
export class SanityModule extends AbstractModule { private readonly options: SanityModuleOptions;
resolvers = resolvers; typedefs = typeDefs;
constructor(options: SanityModuleOptions) { super(); this.options = options; }
override init(): void { setSanityClients(createSanityClients(this.options.config)); }
override getHttpConfig(): HttpConfig { const handler = createWebhookHandler({ messagePublisher: this.options.messagePublisher, }); return { routes: (app) => { app.route({ url: this.options.webhookPath ?? "/api/webhook", method: ["GET", "POST"], handler, }); }, }; }}The webhook handler receives Sanity’s change notifications, flushes the
cache, and publishes a content-modified event through the injected
MessagePublisher — same as packages/storyblok/src/webhook.ts.
Keep the constructor cheap and side-effect free: platform codegen instantiates
the module and calls getGraphQLSchema() without runtime config or network
access.
Publish the package to your own registry (or keep it as a workspace package
in your project’s monorepo) and pin it in your catalog: like the other
framework packages. If the integration is broadly useful, consider
submitting it upstream so it can ship as an official
@evolve-framework/* package.
7. Create the thin platform service
Section titled “7. Create the thin platform service”Create backend/services/cms-sanity/ following backend/services/cms-storyblok.
Four source files:
import { SanityModule } from "@evolve-framework/sanity";import { resolvePublisher } from "@evolve-packages/cloud-adapters";import { config } from "./config.ts";
export const createModule = (): SanityModule => { return new SanityModule({ config: { projectId: config.SANITY_PROJECT_ID, dataset: config.SANITY_DATASET, apiToken: config.SANITY_API_TOKEN, previewToken: config.SANITY_PREVIEW_TOKEN, }, messagePublisher: resolvePublisher(config.INTERNAL_EVENTS_TARGET), });};import { createDomainService } from "@evolve-framework/core";import { cache } from "@evolve-framework/core/cache";import { type ContextValue, initContextValue,} from "@evolve-framework/sanity/graphql/context";import { config, loadConfig } from "#src/config.ts";import { createModule } from "./module.ts";
export type { ContextValue };
export const startServer = async (): Promise<void> => { await loadConfig(); await cache.configure(config.REDIS_URL); const service = await createDomainService({ name: config.COMPONENT_NAME, module: createModule(), graphql: { context: initContextValue, }, http: { address: { host: config.HTTP_HOST, port: config.HTTP_PORT, }, }, }); await service.start();};config.ts extends BaseConfig from @labdigital/enviconf with
COMPONENT_NAME = "cms", HTTP_PORT = 4004, and the Sanity variables;
index.ts just imports and awaits startServer(). Add
@evolve-framework/sanity to the service’s package.json with catalog: as
the version and pin the version in pnpm-workspace.yaml.
The service’s codegen.ts generates schema.generated.graphql directly from
the module — no schema copy in the platform repo:
// codegen.ts (excerpt)import { createModule } from "./src/module.ts";
const config: CodegenConfig = { schema: createModule().getGraphQLSchema(), generates: { "schema.generated.graphql": { plugins: ["schema-ast"] }, },};8. Register the supergraph slot
Section titled “8. Register the supergraph slot”The gateway composes one cms subgraph slot, and which CMS fills it is a
compose-time choice. The Taskfile in backend/services/graphql-gateway
points the slot at ../cms-{{.CMS}}/schema.generated.graphql, so once your
service directory follows the cms-<name> convention it works out of the
box:
task -d backend/services/graphql-gateway supergraph CMS=sanityThe default is contentful; pnpm dev:backend picks up whatever CMS is
set to. All CMS services listen on port 4004, so only the schema file differs
between compositions.
9. Add Terraform
Section titled “9. Add Terraform”Follow the tri-cloud pattern from “Creating a service”
and the existing backend/services/cms-storyblok/terraform/ layout. Inject
the Sanity tokens as secrets through Key Vault (Azure), Secrets Manager
(AWS), or Secret Manager (GCP), never as plain environment variables.
Sanity webhooks are configured through the Sanity management API or the
project dashboard (there is no first-party Terraform resource, unlike
Storyblok’s storyblok_webhook on AWS). Point the webhook at the service’s
/api/webhook endpoint.
Register the component in Mach Composer following the
registration pattern.
The integrations list does not include commercetools — the CMS
service doesn’t interact with commercetools directly.
10. Frontend: rich text and preview
Section titled “10. Frontend: rich text and preview”Two frontend pieces are CMS-specific; everything else works unchanged because all block components consume the shared GraphQL types.
Rich text: frontend/site/src/components/content-blocks/rich-text.tsx
switches on the renderer field. Add a sanity case that renders Portable
Text via @portabletext/react:
case "sanity": return <PortableText value={JSON.parse(content)} />;Preview package: each CMS has a frontend package under
frontend/packages/ (contentful-preview, storyblok-preview) enabling
live editing in the CMS’s visual editor. They share a four-export structure:
| Export | Purpose |
|---|---|
./api-route |
Next.js route handler that validates a secret and enables draft mode |
./component |
Returns data attributes for in-page click-to-edit |
./provider |
Client component that initializes the live preview bridge |
./server |
Server utilities for preview cookies and draft mode |
Create @evolve-storefront/sanity-preview with the same exports (use
@sanity/visual-editing for the provider), then register the new cases in
the site’s CMS abstraction layer, which switches on the EVOLVE_CMS
environment variable:
frontend/site/src/lib/cms-preview.ts— addsanitycases to thecmsPreviewAttributesandgetContentPreviewIDswitchesfrontend/site/src/lib/cms-preview.client.tsx— add the provider case
Set EVOLVE_CMS=sanity in the frontend environment to activate it.
Checklist
Section titled “Checklist”- Framework package
@evolve-framework/sanitycreated, mirroringpackages/storyblok(schema, codegen, clients, mappers, resolvers, loaders, webhook, testing helpers) -
schema.graphqlcopied from an existing CMS package (addsanitytoRichTextRendererenum) -
SanityModuleextendsAbstractModule, assignsresolvers/typedefs, builds clients ininit(), registers the webhook ingetHttpConfig() - Package published (or workspace-linked) and pinned in the
catalog:pnpm-workspace.yamlcatalog - Thin platform service
backend/services/cms-sanitywithconfig.ts+module.ts+server.ts+index.ts, codegen producingschema.generated.graphql - Supergraph composes with
task supergraph CMS=sanity - Terraform deploys the service; webhook registered in Sanity
- Frontend: Portable Text renderer case, preview package,
cms-previewabstraction updated,EVOLVE_CMS=sanityset - Run
pnpm codegenafter schema changes andpnpm checkto verify
Further reading
Section titled “Further reading”- Creating a new domain service for the full service scaffolding pattern and Terraform setup
- Adding a CMS content block for adding new block types to an existing CMS integration
- Customization for extending the schema with project-specific types

