Skip to main content

Adding a new CMS

Evolve's CMS layer is designed as a pluggable adapter: every CMS service 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 service from scratch, using Sanity as the example.

How the CMS layer works

Both existing CMS services (cms-storyblok and cms-contentful) implement an identical schema.graphql. 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 service 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 service is behind the gateway.

Future direction

Currently each CMS service copies the shared schema.graphql and implements all mappers, resolvers, and client logic inline. In the future this will move towards the pattern used by commercetools: a shared framework package (@evolve-framework/cms) that owns the schema, base resolvers, and common mapper utilities, with per-CMS adapter packages providing only the CMS-specific client and mapping logic. See the framework architecture and customization docs for how this pattern works for commercetools today. This is a work in progress; for now, follow the self-contained service approach described below.

1. Scaffold the service

Create the new service directory based on the existing CMS services:

backend/services/cms-sanity/
├── src/
│ ├── index.ts
│ ├── server.ts
│ ├── init.ts
│ ├── context.ts
│ ├── config.ts
│ ├── sanity.ts # Sanity client setup
│ ├── sanity.types.ts # TypeScript types for Sanity documents
│ ├── cache.ts
│ ├── handlers.ts # Webhook handler
│ ├── resolvers/
│ │ ├── index.ts
│ │ └── query/
│ │ ├── page.ts
│ │ ├── pages.ts
│ │ ├── site-layout.ts
│ │ └── content-snippet.ts
│ ├── mappers/
│ │ ├── pages.ts # ISanityDocument → ContentPage/CatalogPage
│ │ ├── map-blocks.ts # Block array mapper (switch on _type)
│ │ ├── map-asset.ts # Sanity image → ContentAsset
│ │ └── map-resource-link.ts
│ ├── loaders/
│ │ └── snippets-by-slug-loader.ts
│ ├── events/
│ │ └── internal/
│ │ └── handler.ts
│ └── functions/
│ ├── aws/
│ │ └── event-handler.ts
│ └── azure/
│ └── internal-events/
│ └── index.ts
├── run.ts
├── build.ts
├── schema.graphql # Copied from cms-storyblok (shared contract)
├── terraform/
│ ├── aws/
│ ├── azure/
│ └── gcp/
└── package.json

The schema.graphql is copied verbatim from cms-storyblok. This is the shared contract that makes CMS services interchangeable.

Add the dependencies to package.json:

{
"name": "@evolve-platform/cms-sanity",
"dependencies": {
"@evolve-framework/core": "workspace:*",
"@evolve-packages/observability": "workspace:*",
"@sanity/client": "^6.0.0",
"dataloader": "^2.2.3"
}
}

2. Configure the Sanity client

Create a config class with the Sanity-specific environment variables:

// src/config.ts
import { BaseConfig } from "@labdigital/enviconf";

class Config extends BaseConfig {
COMPONENT_NAME = "cms";
HTTP_HOST = "localhost";
HTTP_PORT = 4004;

SANITY_PROJECT_ID = "";
SANITY_DATASET = "production";
SANITY_API_TOKEN = ""; // Read token
SANITY_PREVIEW_TOKEN = ""; // Preview/draft token
SANITY_API_VERSION = "2025-01-01";

REDIS_URL = "";
INTERNAL_EVENTS_TARGET = "";
}

export const config = new Config();

Initialize the Sanity client as a module-level singleton, following the pattern in cms-storyblok/src/storyblok.ts:

// src/sanity.ts
import { createClient, type SanityClient } from "@sanity/client";

export let sanityClient: SanityClient;
export let sanityPreviewClient: SanityClient;

export const initSanityClients = (): void => {
sanityClient = createClient({
projectId: config.SANITY_PROJECT_ID,
dataset: config.SANITY_DATASET,
apiVersion: config.SANITY_API_VERSION,
token: config.SANITY_API_TOKEN,
useCdn: true,
});

sanityPreviewClient = createClient({
projectId: config.SANITY_PROJECT_ID,
dataset: config.SANITY_DATASET,
apiVersion: config.SANITY_API_VERSION,
token: config.SANITY_PREVIEW_TOKEN,
useCdn: false,
perspective: "drafts",
});
};

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.

3. Wire the service files

The entry point files follow the exact same pattern as every other service:

// src/init.ts
export const initEnvironment = async (): Promise<void> => {
await loadConfig();
await configureCache();
initSanityClients();
};
// src/server.ts
export const createApp = (): DomainService<ContextValue> =>
new DomainService<ContextValue>({
name: config.COMPONENT_NAME,
graphql: { typeDefs, resolvers, context: newContext, plugins: [] },
http: {
address: { host: config.HTTP_HOST, port: config.HTTP_PORT },
routes: (app) => {
app.route({
url: "/api/webhook",
method: ["POST"],
handler: webhookHandler,
});
},
},
});

The /api/webhook route receives Sanity webhook payloads and clears the cache.

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:

// src/mappers/map-blocks.ts
import type { ContentPage } from "../generated/types.ts";

export const mapBlocks = (
blocks: SanityBlock[] | undefined,
storeContext: StoreContext,
): ContentPage["body"] => {
if (!blocks) return [];

return blocks
.map((block) => {
switch (block._type) {
case "hero":
return mapHeroBlock(block, storeContext);
case "teaserRow":
return mapTeasersBlock(block, storeContext);
case "uspList":
return mapUspBlock(block, storeContext);
case "contentTeaser":
return mapTeaserBlock(block, storeContext);
case "richText":
return mapOneColumnBlock(block, storeContext);
default:
logger.error({ msg: "Unknown block type", block });
return undefined;
}
})
.filter(isValue);
};

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: data.link ? mapResourceLink(data.link) : null,
image: data.image ? mapSanityImage(data.image) : null,
imagePosition: data.imagePosition ?? "left",
});

Note that Sanity uses _key for array items (vs. Storyblok's _uid).

For the RichText block type, the GraphQL schema has a renderer enum that currently supports contentful and storyblok. You'll need to add sanity to this enum in the shared schema:

enum RichTextRenderer {
contentful
storyblok
sanity
}

And serialize Sanity's Portable Text as JSON, just as Storyblok serializes its rich text:

const mapRichText = (data: SanityRichText): RichText => ({
__typename: "RichText",
id: data._key,
content: JSON.stringify(data.content),
renderer: "sanity",
});

The frontend then uses the renderer field to pick the right rich text renderer component.

5. Implement the resolvers

The resolvers use GROQ queries to fetch content from Sanity. Implement the same set of query resolvers that both existing CMS services provide:

// src/resolvers/query/page.ts
export const pageResolver = (contentType?: string) =>
async (_parent: unknown, args: { path: string }, ctx: ContextValue) => {
const query = `*[_type == $type && slug.current == $slug][0]`;
const doc = await sanityClient.fetch(query, {
type: contentType ?? "contentPage",
slug: args.path,
});

if (!doc) return null;
return mapPage(doc, ctx.storeContext);
};

The full resolver map mirrors the existing services:

// src/resolvers/index.ts
export const resolvers: Resolvers = {
Mutation: { storeContentPreview },
Query: {
page: pageResolver(undefined),
pages: pagesResolver,
catalogPage: pageResolver("catalogPage"),
contentPage: pageResolver("contentPage"),
siteLayout: siteLayoutResolver,
contentSnippet: contentSnippetResolver,
},
SiteLayout: siteLayoutFieldResolvers,
};

6. Handle webhooks

Sanity sends webhook payloads when content changes. The handler clears the cache and publishes a content-modified-event, same as the existing services:

// src/handlers.ts
export const webhookHandler = async (
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> => {
await flushCache();

const body = request.body as SanityWebhookPayload;
if (body?.ids?.updated || body?.ids?.created) {
await publishContentModifiedEvent(body);
}

reply.send({ ok: true });
};

7. Add Terraform

The Terraform setup has two parts: the standard Evolve service deployment and the Sanity-specific resource provisioning.

Service deployment

Follow the tri-cloud pattern from "Creating a service" for the Container Apps (Azure), ECS (AWS), and Cloud Run (GCP) deployment. The locals.tf injects Sanity-specific environment variables:

locals {
service_name = "cms-sanity"

env_vars = {
NODE_ENV = "production"
SERVICE_NAME = local.service_name
SITE = var.site
SANITY_PROJECT_ID = var.variables.sanity_project_id
SANITY_DATASET = var.variables.sanity_dataset
SANITY_API_VERSION = "2025-01-01"
REDIS_URL = "rediss://${data.azurerm_redis_cache.cache.hostname}:6380?connector=azure"
# ... OTEL, Sentry config
}
}

The Sanity tokens are injected as secrets through Key Vault (Azure), Secrets Manager (AWS), or Secret Manager (GCP), never as plain environment variables.

Sanity provider for dataset provisioning

A community Terraform provider exists for managing Sanity resources. Add it to versions.tf:

terraform {
required_providers {
sanity = {
source = "tessellator/sanity"
version = ">= 0.1.0"
}
}
}

Use it to provision the dataset and CORS origins:

# terraform/azure/sanity.tf

resource "sanity_dataset" "main" {
project = var.variables.sanity_project_id
name = var.variables.sanity_dataset
acl_mode = "public"
}

resource "sanity_cors_origin" "storefront" {
project = var.variables.sanity_project_id
origin = var.variables.storefront_url
credentials = false
}

This is optional. You may prefer to manage the Sanity project and datasets through the Sanity management UI or CLI instead. The Terraform provider is most useful in multi-environment setups where you want infrastructure-as-code consistency.

Content model management

The Terraform provider does not manage content models (document types, fields, etc.). For development, create your Sanity schema types manually in the Sanity Studio, which gives you the fastest feedback loop.

For deployment to production, use the Sanity CLI to deploy your schema into the dataset:

npx sanity schema deploy

This pushes your Studio's schema definitions into the dataset, enabling features like the Sanity Content Lake's schema-aware validation.

Schema generation

Since the shared schema.graphql already defines all block and page types, you could write a codegen plugin that generates Sanity document and object type definitions from the GraphQL schema, ensuring the CMS content model stays in sync with the backend contract automatically. This is not required to get started, but worth considering for long-term maintainability.

Webhook registration

Unlike Storyblok (which has a storyblok_webhook Terraform resource on AWS), Sanity webhooks are configured through the Sanity management API or the project dashboard. Configure the webhook to point to your service's /api/webhook endpoint.

8. Register in Mach Composer

Add the component to the Mach Composer configuration, following the registration pattern:

# Component registry
- name: cms-sanity
source: ../../backend/services/cms-sanity/terraform/azure/
version: "$LATEST"
branch: "main"
integrations:
- azure
- sentry
- hive

# Site-level config
- name: cms-sanity
variables:
sanity_project_id: "your-project-id"
sanity_dataset: "production"
storefront_url: "https://your-store.example.com"
secrets:
sanity_api_token: ${var.secrets.sanity.api_token}
sanity_preview_token: ${var.secrets.sanity.preview_token}
hive_token: ${var.secrets.hive.api_token}

Note that the integrations list does not include commercetools. The CMS service doesn't interact with commercetools directly.

9. Frontend: rich text rendering

Add a Portable Text renderer for the sanity value of the RichTextRenderer enum. Add a case in the rich text component:

// In the rich text rendering switch
case "sanity":
return <PortableText value={JSON.parse(content)} components={...} />;

Use the @portabletext/react package to render Sanity's Portable Text format. All other block components work unchanged since they receive the same GraphQL types regardless of CMS.

10. Frontend: preview package

Each CMS has a dedicated frontend preview package that enables live editing in the CMS's visual editor. These live under frontend/packages/ and follow a shared four-export structure:

ExportPurpose
./api-routeNext.js API route handler for the preview webhook entry point
./componentReturns data attributes for in-page click-to-edit
./providerClient component that initializes the live preview bridge
./serverServer utilities for preview cookies and draft mode

Create the package at frontend/packages/sanity-preview/:

frontend/packages/sanity-preview/
├── package.json
├── tsconfig.json
└── src/
├── api-route.ts
├── component.ts
├── provider.tsx
└── server.ts
{
"name": "@evolve-storefront/sanity-preview",
"version": "1.0.0",
"type": "module",
"exports": {
"./api-route": "./src/api-route.ts",
"./component": "./src/component.ts",
"./provider": "./src/provider.tsx",
"./server": "./src/server.ts"
},
"dependencies": {
"@sanity/preview-kit": "^5.0.0",
"next": "15.5.9",
"react": "19.2.3"
}
}

API route

The preview handler validates a shared secret, enables Next.js draft mode, and redirects to the content page. Follow the Storyblok pattern of using timingSafeEqual for the secret comparison:

// src/api-route.ts
import { timingSafeEqual } from "node:crypto";
import { cookies, draftMode } from "next/headers";

type HandlerOptions = {
defaultLocale: string;
previewSecret: string;
localePrefix: Record<string, string>;
};

export const createPreviewHandler = (options: HandlerOptions) => {
return async (request: Request): Promise<Response> => {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
const locale = searchParams.get("locale");

if (!secret || !slug || !locale) {
return new Response("Missing parameters", { status: 400 });
}

// Validate the shared secret
const expected = Buffer.from(options.previewSecret);
const received = Buffer.from(secret);
if (expected.length !== received.length ||
!timingSafeEqual(expected, received)) {
return new Response("Unauthorized", { status: 401 });
}

// Enable draft mode and redirect to the content page
const dm = await draftMode();
dm.enable();
const prefix = options.localePrefix[locale] ?? options.localePrefix[options.defaultLocale];
return Response.redirect(new URL(`${prefix}/${slug}`, request.url), 307);
};
};

Register it in the site as a Next.js API route:

// frontend/site/src/app/api/preview-sanity/route.ts
import { randomBytes } from "node:crypto";
import { localePrefixes } from "@evolve-packages/site-config";
import { createPreviewHandler } from "@evolve-storefront/sanity-preview/api-route";

export const GET = createPreviewHandler({
defaultLocale: "nl-NL",
previewSecret: process.env.CMS_SECRET_TOKEN ?? randomBytes(16).toString("hex"),
localePrefix: localePrefixes,
});

Component attributes

Sanity supports content source maps for click-to-edit, similar to Contentful. If you use Sanity's Visual Editing with overlays, the component export can return an empty object (the SDK handles attribution automatically). If you prefer explicit data attributes, return an ID and type:

// src/component.ts
type ComponentFragment = { __typename: string; id: string };

export const sanityDataAttributes = (block: ComponentFragment) => ({
"data-sanity-edit-id": block.id,
"data-sanity-edit-type": block.__typename,
});

Provider

The provider initializes the Sanity live preview bridge as a client component:

// src/provider.tsx
"use client";

import { type ReactNode, useEffect } from "react";

type Props = {
children: ReactNode;
projectId: string;
dataset: string;
};

export function ContentPreviewProvider({ children, projectId, dataset }: Props) {
useEffect(() => {
// Initialize Sanity visual editing overlay
// The @sanity/visual-editing package handles live preview
}, []);

return <>{children}</>;
}

Register in the CMS abstraction layer

The frontend site uses a CMS abstraction layer controlled by the EVOLVE_CMS environment variable. Add the sanity case to both the server and client abstraction files:

// frontend/site/src/lib/cms-preview.ts
import { sanityDataAttributes } from "@evolve-storefront/sanity-preview/component";
import { getPreviewID as getPreviewIDSanity } from "@evolve-storefront/sanity-preview/server";

export const cmsPreviewAttributes = (data: { __typename: string; id: string }) =>
EVOLVE_CMS === "sanity" ? sanityDataAttributes(data)
: EVOLVE_CMS === "contentful" ? contentfulDataAttributes(data)
: EVOLVE_CMS === "storyblok" ? storyblokDataAttributes(data)
: undefined;

export const getContentPreviewID = async () =>
EVOLVE_CMS === "sanity" ? getPreviewIDSanity()
: EVOLVE_CMS === "contentful" ? getPreviewIDContentful()
: EVOLVE_CMS === "storyblok" ? getPreviewIDStoryblok()
: undefined;
// frontend/site/src/lib/cms-preview.client.tsx
import { ContentPreviewProvider as ContentPreviewProviderSanity } from "@evolve-storefront/sanity-preview/provider";

// Add to the switch in ContentPreviewProvider:
case "sanity":
return <ContentPreviewProviderSanity>{children}</ContentPreviewProviderSanity>;

Set EVOLVE_CMS=sanity in the frontend environment to activate the Sanity preview integration.

Checklist

  • schema.graphql copied from an existing CMS service (add sanity to RichTextRenderer enum)
  • Sanity client initialized with CDN and preview variants
  • All mappers translate Sanity documents to shared GraphQL types
  • Resolvers implement the full query surface (page, pages, catalogPage, contentPage, siteLayout, contentSnippet)
  • Webhook handler clears cache and publishes content-modified events
  • Terraform deploys the service and optionally provisions Sanity resources
  • Frontend has a Portable Text renderer for the sanity rich text variant
  • Frontend preview package created (@evolve-storefront/sanity-preview) with api-route, component, provider, and server exports
  • CMS abstraction layer updated (cms-preview.ts, cms-preview.client.tsx) with sanity cases
  • Preview API route registered at /api/preview-sanity/
  • EVOLVE_CMS=sanity set in frontend environment
  • Run pnpm codegen after schema changes and pnpm check to verify

Further reading