A standardized JSON schema abstraction for content models across CMS platforms.
Define once. Deploy anywhere.
Supported platforms: Contentful, ContentStack, Sanity, Storyblok, Umbraco Planned: Optimizely, Sitecore
Content management systems vary significantly in their data models, APIs, and capabilities.
| Spec | Name | Purpose |
|---|---|---|
| UCM | Unified Content Model | Define content types (schemas) |
| UCF | Unified Content Format | Store content instances (data) |
| UCR | Unified Content Retrieval | Fetch content with resolution (API) |
| UCN | Unified Content Notifications | React to content events (webhooks) |
Key benefits:
A single config file switches between CMS platforms. Just change CMS_PLATFORM and provide the matching credentials:
CMS_PLATFORM=contentful
MODELS_PATH=./models
DEFAULT_LOCALE=en-US
# Contentful
CONTENTFUL_SPACE_ID=a1b2c3d4e5
CONTENTFUL_ACCESS_TOKEN=CFPAT-xxxxxxxxxxxxxxxxxxxx
CONTENTFUL_ENVIRONMENT=master
Switch to another CMS by changing the platform and keys:
CMS_PLATFORM=umbraco
UMBRACO_BASE_URL=https://your-instance.euwest01.umbraco.io
UMBRACO_API_KEY=xxxxxxxxxxxxxxxxxxxx
All CLI commands (ucm sync, ucm pull, ucm content push, etc.) read from .cmsconfig automatically.
Every unified content type is a JSON Schema (draft-07) file:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"id": "content-card",
"title": "Content Card",
"description": "A polymorphic card component",
"metadata": {
"localized": true,
"version": "1.0.0",
"displayField": "headline"
},
"structure": {
"allowedAsRoot": true,
"folder": "Cards"
},
"type": "object",
"properties": {
"headline": {
"type": "string",
"title": "Headline"
},
"summary": {
"type": "text",
"title": "Summary"
},
"category": {
"type": "string",
"title": "Category"
},
"data": {
"type": "object",
"title": "Card Data",
"ui": { "widget": "web-config-card" }
}
}
}
| Unified Type | Description | Example Use |
|---|---|---|
string |
Single-line text | Titles, names |
text |
Multi-line text | Descriptions, notes |
number |
Numeric values | Quantities, ratings |
boolean |
True/false | Enable/disable flags |
date |
Date-only | Publication dates |
datetime |
Date and time | Event timestamps |
url |
URL strings | Links |
richtext |
Formatted text | Content bodies |
media |
File references | Images, documents |
reference |
Content links | Related articles |
array |
Ordered lists | Multiple items |
object |
Arbitrary JSON | Settings, config |
select |
Choice options | Dropdowns |
| Unified Type | Description | CMS Support |
|---|---|---|
geopoint |
Geographic coordinates | 6/7 platforms |
slug |
URL-safe identifiers | 7/7 platforms |
color |
Color values (hex, RGB) | 7/7 platforms |
tags |
Tag collections | 7/7 platforms |
{
"type": "geopoint",
"title": "Location",
"description": "Geographic coordinates for mapping",
"validation": { "required": false }
}
Adapter maps to Location (Contentful), geopoint (Sanity), Coordinate (Sitecore), or JSON object (others).
The structure property controls how content types appear and behave in CMS interfaces:
{
"structure": {
"allowedAsRoot": true,
"folder": "Layouts",
"allowedChildContentTypes": ["content-card"],
"layout": [
{
"area": "main",
"type": "group",
"name": "Identity & Business",
"description": "Basic site configuration",
"collapse": false,
"fields": ["site_name", "site_url", "contact_email"]
},
{
"area": "main",
"type": "group",
"name": "SEO & Social",
"collapse": true,
"fields": ["og_image", "twitter_card", "twitter_site"]
}
]
}
}
folder to content type groups, layout to editor interface tabsfolder to document type containers, layout to property groups/tabsWidgets map custom CMS editors to unified schema fields via widget-registry.json:
{
"widgets": {
"web-config-card": { "contentful": "1BNE...", "umbraco": "BrowserStyle.WebConfigCard" },
"web-config-csp": { "contentful": "13dq...", "umbraco": "BrowserStyle.WebConfigCSP" },
"web-config-manifest": { "contentful": "7bFH...", "umbraco": "BrowserStyle.WebConfigManifest" },
"web-config-robots": { "contentful": "7Brx...", "umbraco": "BrowserStyle.WebConfigRobots" },
"web-config-security": { "contentful": "1pEB...", "umbraco": "BrowserStyle.WebConfigSecurity" },
"web-config-alt": { "umbraco": "BrowserStyle.WebConfigAlt" }
}
}
{
"csp": {
"type": "object",
"title": "Content Security Policy",
"ui": { "widget": "web-config-csp" }
}
}
The adapter reads the widget name, looks up the CMS-specific editor ID, and configures the property editor accordingly.
ucm validate # Validate all schemas
ucm validate models/page.schema.json # Validate specific file
ucm sync # Push all schemas to CMS
ucm sync --file models/content-card.schema.json # Push single schema
ucm pull # Pull schemas from CMS
ucm pull --output ./backup # Pull to custom directory
ucm diff # Show differences between local and remote
ucm clean # Delete content types from CMS
ucm clean --inverse # Delete types NOT in local models
ucm backup # Backup CMS content types locally
| Flag | Description |
|---|---|
-m, --model <model> |
Filter by content model |
-d, --dry-run |
Preview without applying |
-v, --verbose |
Detailed output |
While UCM defines content types (schemas), UCF defines content instances (the actual data).
UCF enables:
{
"$schema": "../../models/article.schema.json",
"id": "getting-started-with-ucm",
"model": "article",
"meta": {
"createdAt": "2026-01-10T09:00:00Z",
"updatedAt": "2026-01-12T14:30:00Z",
"locale": "en-US",
"status": "published",
"contentful": {
"entryId": "3Kt1Th8vZ4TrBNzwFjMUXW",
"version": 3
}
},
"fields": {
"title": "Getting Started with UCM",
"slug": "getting-started-with-ucm",
"summary": "Learn how to use the Unified Content Model.",
"body": {
"$richtext": true,
"content": "<h2>Introduction</h2><p>Welcome to UCM...</p>",
"format": "html"
},
"author": { "$ref": "author/jane-doe" },
"heroImage": { "$asset": "assets/ucm-hero", "assetId": "ucm-hero" },
"tags": ["tutorial", "getting-started"],
"featured": true
}
}
$ref){
"author": { "$ref": "author/jane-doe" },
"relatedArticles": [
{ "$ref": "article/getting-started" },
{ "$ref": "article/advanced-usage" }
]
}
Composite key format: {model}/{content-id} -- resolved via manifest during push.
{
"heroImage": { "$asset": "assets/hero-banner", "assetId": "hero-banner" }
}
{
"body": {
"$richtext": true,
"content": "<p>This is <strong>rich</strong> text.</p>",
"format": "html"
}
}
The manifest tracks CMS-specific IDs and content dependencies:
{
"environments": {
"umbraco:production": {
"article/getting-started": "e9a2c384-7d2f-...",
"author/jane-doe": "4b63de2b-f7fc-..."
},
"contentful:master": {
"article/getting-started": "3Kt1Th8vZ4TrBNzwFjMUXW",
"author/jane-doe": "6eQyQIsfPhaKAiwoJasEE6"
}
},
"dependencies": {
"article/getting-started": ["author/jane-doe"]
},
"restoreOrder": ["author/jane-doe", "article/getting-started"]
}
References are resolved to CMS-native formats automatically:
{ "sys": { "type": "Link", "linkType": "Entry", "id": "..." } }
{ "udi": "umb://document/..." }
Multi-locale content through locale-suffixed filenames:
.ucm/content/article/
getting-started.json <- en-US (default)
getting-started.da.json <- Danish
getting-started.de-DE.json <- German
Multiple locale files map to the same CMS entry -- just different locale slots.
ucm content pull # Pull default locale
ucm content pull --locale da # Pull specific locale
ucm content pull --all-locales # Pull all locales
ucm content push # Push all locale files
ucm content push --dry-run # Preview without applying
ucm content pull # Pull all content
ucm content pull --model article # Pull specific model
ucm content pull --model article --id home # Pull specific item
ucm content pull --with-assets # Include media files
ucm content push # Push all content
ucm content push --model article # Push specific model
ucm content push --dry-run # Preview changes
ucm content push --yes # Skip confirmation
CMS_PLATFORM=umbraco ucm content pull --output ./migration/from-umbraco
CMS_PLATFORM=contentful ucm content push --input ./migration/from-umbraco
A platform-agnostic API for fetching content from any CMS with automatic reference resolution.
const content = await fetchContent(model, id, depth);
model -- Any content type (e.g. page, layout, content-card)id -- Content identifier (slug, ID, or path)depth -- false returns reference IDs, true resolves all nested content| Platform | Status |
|---|---|
| Contentful | Full |
| Umbraco | Full |
| Storyblok | Full |
| Contentstack | Full |
| Sanity | Planned |
depth Attribute{
"id": "home", "model": "page",
"title": "Home Page",
"layouts": [
{ "$ref": "layout/hero-layout" },
{ "$ref": "layout/features-layout" }
]
}
{
"id": "home", "model": "page",
"title": "Home Page",
"layouts": [{
"id": "hero-layout", "model": "layout",
"title": "Hero Section",
"content": [{
"id": "news-1", "model": "content-card",
"headline": "The Future of Quantum Computing",
"data": { "type": "news" }
}]
}]
}
Contentful and Umbraco return identical normalized content:
{
"id": "perfect-chocolate-chip-cookies",
"model": "content-card",
"headline": "Perfect Chocolate Chip Cookies",
"summary": "The ultimate guide to baking soft, chewy...",
"category": "Recipe",
"data": {
"type": "recipe",
"content": { "readingTime": "Prep time: 45 min" },
"ribbon": { "text": "Popular", "style": "popular" },
"engagement": { "reactions": [{ "type": "like", "count": 530 }] }
}
}
{
"id": "recipe/recipe-3",
"model": "content-card",
"headline": "Perfect Chocolate Chip Cookies",
"summary": "The ultimate guide to baking soft, chewy...",
"category": "Recipe",
"data": {
"type": "recipe",
"content": { "readingTime": "Prep time: 45 min" },
"ribbon": { "text": "Popular", "style": "popular" },
"engagement": { "reactions": [{ "type": "like", "count": 530 }] }
}
}
Same shape. Same data. Different CMS. One API.
A platform-agnostic webhook listener that normalizes CMS events into a unified format.
CMS Webhook --> UCN Server --> Normalizer --> Unified Event --> Handlers
{
"event": "content.published",
"platform": "contentful",
"model": "content-card",
"entryId": "abc-456",
"locale": "en-US",
"timestamp": "2026-02-16T12:00:00Z"
}
| Event | Typical Use |
|---|---|
content.published |
Vectorize for AI search, rebuild sitemap |
content.unpublished |
Remove from sitemap and search |
content.deleted |
Remove from search index |
content.draft |
Preview builds |
media.uploaded |
Generate alt text, optimize images |
schema.updated |
Update search mappings |
// handlers/vectorize.js
export default async function handle(event) {
if (event.event === 'content.deleted') {
await searchIndex.remove(event.model, event.entryId);
return;
}
const content = await fetchContent(event.model, event.entryId, true);
await searchIndex.upsert(content);
}
// handlers/sitemap.js
export default async function handle(event) {
if (!event.event.startsWith('content.')) return;
await fetch(SITEMAP_REBUILD_URL, {
method: 'POST',
body: JSON.stringify({ reason: event.event, model: event.model })
});
}
{
"handlers": {
"*": ["./handlers/logger.js"],
"content.published": ["./handlers/vectorize.js", "./handlers/sitemap.js"],
"content.deleted": ["./handlers/vectorize.js", "./handlers/sitemap.js"]
}
}
| Component | What it does |
|---|---|
| UCM | Define content types once -- deploy to any CMS |
| UCF | Store and migrate content instances across platforms |
| UCR | Fetch content with a single API, regardless of CMS |
| UCN | React to content events from any CMS via webhooks |
One schema. Any CMS. Full portability.