Skip to content

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

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

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 components

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

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.

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.

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():

src/lib/clients.ts
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.

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

Implement the same resolver surface both existing packages provide — compare packages/storyblok/src/resolvers/index.ts:

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.

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:

src/modules/sanity.ts
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.

Create backend/services/cms-sanity/ following backend/services/cms-storyblok. Four source files:

src/module.ts
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),
});
};
src/server.ts
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"] },
},
};

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:

Terminal window
task -d backend/services/graphql-gateway supergraph CMS=sanity

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

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.

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 — add sanity cases to the cmsPreviewAttributes and getContentPreviewID switches
  • frontend/site/src/lib/cms-preview.client.tsx — add the provider case

Set EVOLVE_CMS=sanity in the frontend environment to activate it.

  • Framework package @evolve-framework/sanity created, mirroring packages/storyblok (schema, codegen, clients, mappers, resolvers, loaders, webhook, testing helpers)
  • schema.graphql copied from an existing CMS package (add sanity to RichTextRenderer enum)
  • SanityModule extends AbstractModule, assigns resolvers/typedefs, builds clients in init(), registers the webhook in getHttpConfig()
  • Package published (or workspace-linked) and pinned in the catalog: pnpm-workspace.yaml catalog
  • Thin platform service backend/services/cms-sanity with config.ts + module.ts + server.ts + index.ts, codegen producing schema.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-preview abstraction updated, EVOLVE_CMS=sanity set
  • Run pnpm codegen after schema changes and pnpm check to verify