2025-10-26
Multi-Channel Content Management: Navigating the Headless CMS Landscape
A practical comparison of headless CMS solutions - Strapi, Contentful, Kontent, and Storyblok - including image management with Cloudinary and framework integration patterns for web and mobile applications.
Abstract
Choosing a headless CMS becomes complex when you need to serve content to web, mobile, and potentially IoT devices simultaneously. The “best” choice depends heavily on your team’s workflow, technical constraints, and content editing experience requirements. This guide compares four major players - Strapi, Contentful, Kontent, and Storyblok - with practical insights on image management, framework integration, and the architectural decisions that matter.
The Multi-Channel CMS Landscape
Traditional content management systems like WordPress tightly couple content creation, storage, and presentation. Headless CMS solutions break this pattern by providing content as data through APIs, letting you build the presentation layer however you want.
Moving to a headless architecture enables the marketing team to update content without waiting for deployments, mobile apps to share the same content source as the web, and teams to experiment with different frontend frameworks without migrating content. But it also introduces new challenges - API rate limits during traffic spikes, cache invalidation complexity, and the need to build editing experiences that non-technical users can actually use.
Why Multi-Channel Delivery Matters
A multi-channel CMS architecture means your content management layer runs independently from your application servers. This separation provides:
- Multi-channel delivery: Same content, different presentations (web, mobile, digital signage, etc.)
- Technology flexibility: Swap React for Vue without touching your content
- Scale independently: Content API can scale separately from your application
- Team autonomy: Content editors work independently of development cycles
The trade-off? You’re now managing two systems instead of one, with all the complexity that entails.
Evaluation Framework
Before diving into specific solutions, here’s what matters when choosing a multi-channel CMS:
Key Decision Points
API Design: GraphQL gives you flexible queries but adds complexity. REST is simpler but requires more endpoints.
Editing Experience: Non-technical users need visual feedback. Developers might prefer schema-first approaches.
Framework Support: First-class SDK support saves weeks of integration work.
Deployment Model: Self-hosted gives control but requires maintenance. SaaS is faster to start but with less flexibility.
Pricing Structure: Watch out for hidden costs in API calls, bandwidth, or storage as you scale.
Strapi: The Self-Hosted Contender
Strapi is the open-source option that gives you complete control. You host it, you own the data, you customize everything.
Architecture Overview
// Strapi content type definition
// /api/article/content-types/article/schema.json
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"content": {
"type": "richtext"
},
"coverImage": {
"type": "media",
"multiple": false,
"allowedTypes": ["images"]
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category"
},
"publishedAt": {
"type": "datetime"
}
}
}
Client Integration
// Next.js integration with Strapi
import qs from 'qs';
interface StrapiArticle {
id: number;
attributes: {
title: string;
content: string;
coverImage: {
data: {
attributes: {
url: string;
formats: Record<string, { url: string }>;
};
};
};
category: {
data: {
attributes: {
name: string;
};
};
};
publishedAt: string;
};
}
async function getArticles(): Promise<StrapiArticle[]> {
const query = qs.stringify({
populate: ['coverImage', 'category'],
sort: ['publishedAt:desc'],
pagination: {
pageSize: 10,
},
}, { encodeValuesOnly: true });
const res = await fetch(
`${process.env.STRAPI_URL}/api/articles?${query}`,
{
headers: {
Authorization: `Bearer ${process.env.STRAPI_TOKEN}`,
},
next: { revalidate: 60 }, // Cache for 60 seconds
}
);
const data = await res.json();
return data.data;
}
// React Native usage
async function fetchArticlesForMobile() {
const query = qs.stringify({
populate: ['coverImage'],
fields: ['title', 'excerpt', 'publishedAt'], // Lighter payload for mobile
pagination: {
pageSize: 20,
},
}, { encodeValuesOnly: true });
const response = await fetch(
`${STRAPI_URL}/api/articles?${query}`,
{
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
}
);
return response.json();
}
What Works Well
Complete Control: You can modify anything - from the admin panel to API responses. I’ve added custom authentication flows, integrated with legacy systems, and customized the content model without hitting platform limitations.
Cost-Effective at Scale: Once you’re handling thousands of API requests per minute, self-hosting becomes significantly cheaper than SaaS pricing.
Plugin Ecosystem: Need to integrate with a specific service? There’s probably a plugin, or you can build one.
Gotchas I’ve Encountered
Media Handling: Strapi’s built-in media library works for small sites, but at scale you’ll want to integrate with Cloudinary or similar. The default upload handling isn’t production-ready for high-traffic sites.
Upgrade Path: Major version upgrades can be painful. Moving from Strapi v3 to v4 required significant code changes. Plan for migration time.
Performance Tuning: Out of the box, Strapi’s database queries aren’t optimized. You’ll need to add caching, optimize relations, and potentially write custom database queries.
// Performance optimization: Custom service to reduce N+1 queries
// /src/api/article/services/article.ts
export default factories.createCoreService('api::article.article', ({ strapi }) => ({
async findWithOptimizedRelations(params) {
// Instead of letting Strapi populate relations automatically,
// use a raw query to avoid N+1 problems
const articles = await strapi.db.query('api::article.article').findMany({
...params,
populate: {
category: true,
coverImage: {
select: ['url', 'formats'],
},
},
});
return articles;
},
}));
When to Choose Strapi
- You have DevOps capacity to manage self-hosted infrastructure
- You need extensive customization or integration with existing systems
- API call volume makes SaaS pricing prohibitive
- Data sovereignty requires on-premise hosting
Contentful: The Enterprise Standard
Contentful is the established enterprise player with a robust API, extensive documentation, and mature tooling. It’s what you choose when you need reliability and support.
Content Modeling
// Contentful TypeScript types (generated from content model)
import { Entry, Asset } from 'contentful';
interface ArticleFields {
title: string;
slug: string;
content: Document; // Rich text as structured content
featuredImage: Asset;
category: Entry<CategoryFields>;
tags: string[];
publishDate: string;
author: Entry<AuthorFields>;
}
type Article = Entry<ArticleFields>;
// Client setup with caching
import { createClient } from 'contentful';
const client = createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
environment: process.env.CONTENTFUL_ENVIRONMENT || 'master',
});
// Fetch with link resolution
async function getArticle(slug: string): Promise<Article | null> {
const entries = await client.getEntries<ArticleFields>({
content_type: 'article',
'fields.slug': slug,
include: 2, // Resolve links 2 levels deep
limit: 1,
});
return entries.items[0] || null;
}
// GraphQL query alternative
const ARTICLE_QUERY = `
query GetArticle($slug: String!) {
articleCollection(where: { slug: $slug }, limit: 1) {
items {
title
slug
content {
json
}
featuredImage {
url
width
height
description
}
category {
name
slug
}
sys {
publishedAt
}
}
}
}
`;
async function getArticleViaGraphQL(slug: string) {
const response = await fetch(
`https://graphql.contentful.com/content/v1/spaces/${process.env.CONTENTFUL_SPACE_ID}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.CONTENTFUL_ACCESS_TOKEN}`,
},
body: JSON.stringify({
query: ARTICLE_QUERY,
variables: { slug },
}),
}
);
const data = await response.json();
return data.data.articleCollection.items[0];
}
React Native Integration
// Mobile app integration with offline support
import { createClient } from 'contentful';
import AsyncStorage from '@react-native-async-storage/async-storage';
const CACHE_KEY = 'contentful_cache';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
class ContentfulMobileClient {
private client = createClient({
space: Config.CONTENTFUL_SPACE_ID,
accessToken: Config.CONTENTFUL_ACCESS_TOKEN,
});
async getArticles(useCache = true): Promise<Article[]> {
if (useCache) {
const cached = await this.getCachedData();
if (cached) return cached;
}
const entries = await this.client.getEntries<ArticleFields>({
content_type: 'article',
order: '-fields.publishDate',
limit: 50,
// Optimize for mobile: only fetch necessary fields
select: 'fields.title,fields.slug,fields.excerpt,fields.featuredImage,sys.id',
});
await this.cacheData(entries.items);
return entries.items;
}
private async getCachedData(): Promise<Article[] | null> {
try {
const cached = await AsyncStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > CACHE_DURATION) {
return null;
}
return data;
} catch {
return null;
}
}
private async cacheData(data: Article[]): Promise<void> {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now(),
}));
}
}
What Works Well
Rich Text Handling: Contentful’s structured rich text format is powerful. Instead of HTML strings, you get a structured document that you can render differently on web vs. mobile.
GraphQL Support: The GraphQL API is well-designed. You can request exactly what you need, which is great for mobile apps with bandwidth constraints.
Webhooks and Sync API: The Sync API is excellent for building offline-first mobile apps. You can incrementally sync content changes.
Content Preview: The preview API lets editors see unpublished content in your actual application - crucial for content teams.
Gotchas I’ve Encountered
Pricing Complexity: The pricing model based on content types, locales, and API calls can get expensive. I’ve seen monthly costs jump unexpectedly when content volume increased.
Rate Limits: The free tier has aggressive rate limits (5 requests/second). In production, you’ll need caching strategies:
// Implement request batching to avoid rate limits
class BatchedContentfulClient {
private requestQueue: Array<() => Promise<any>> = [];
private processing = false;
async enqueue<T>(request: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.requestQueue.push(async () => {
try {
const result = await request();
resolve(result);
} catch (error) {
reject(error);
}
});
this.processQueue();
});
}
private async processQueue() {
if (this.processing || this.requestQueue.length === 0) return;
this.processing = true;
const batch = this.requestQueue.splice(0, 4); // 4 requests per batch
await Promise.all(batch.map(fn => fn()));
// Wait 200ms before next batch (5 requests/second limit)
await new Promise(resolve => setTimeout(resolve, 200));
this.processing = false;
this.processQueue(); // Process next batch
}
}
Link Resolution: The include parameter for link resolution can lead to large payloads. Be explicit about what you need.
When to Choose Contentful
- You need enterprise-grade reliability and support
- Your team values extensive documentation and community resources
- You’re building multi-platform applications (web, mobile, IoT)
- GraphQL API design fits your architecture
Kontent.ai: The Content Modeling Specialist
Kontent (formerly Kentico Kontent) focuses on content modeling and governance. It’s designed for organizations with complex content structures and multiple teams.
Content Architecture
// Kontent.ai SDK integration
import { DeliveryClient, Elements } from '@kentico/kontent-delivery';
import { camelCasePropertyNameResolver } from '@kentico/kontent-core';
interface Article {
title: Elements.TextElement;
slug: Elements.UrlSlugElement;
content: Elements.RichTextElement;
featuredImage: Elements.AssetsElement;
category: Elements.LinkedItemsElement<Category>;
tags: Elements.TaxonomyElement;
publishDate: Elements.DateTimeElement;
}
const deliveryClient = new DeliveryClient({
projectId: process.env.KONTENT_PROJECT_ID!,
previewApiKey: process.env.KONTENT_PREVIEW_API_KEY,
defaultQueryConfig: {
usePreviewMode: process.env.NODE_ENV === 'development',
},
propertyNameResolver: camelCasePropertyNameResolver,
});
// Type-safe content fetching
async function getArticleBySlug(slug: string) {
const response = await deliveryClient
.items<Article>()
.type('article')
.equalsFilter('elements.slug', slug)
.depthParameter(2)
.toPromise();
return response.data.items[0];
}
// Fetch with filtering and sorting
async function getArticlesByCategory(categorySlug: string) {
const response = await deliveryClient
.items<Article>()
.type('article')
.containsFilter('elements.category', [categorySlug])
.orderByDescending('elements.publish_date')
.limitParameter(20)
.toPromise();
return response.data.items;
}
Taxonomy and Content Relationships
// Kontent excels at taxonomies for categorization
interface TaxonomyTerm {
name: string;
codename: string;
}
async function getArticlesWithTaxonomy() {
const response = await deliveryClient
.items<Article>()
.type('article')
.toPromise();
// Articles come with fully resolved taxonomy terms
return response.data.items.map(item => ({
title: item.elements.title.value,
tags: item.elements.tags.value.map((term: TaxonomyTerm) => term.name),
category: item.elements.category.linkedItems[0],
}));
}
// React Native implementation
import { DeliveryClient } from '@kentico/kontent-delivery';
class KontentMobileService {
private client: DeliveryClient;
constructor() {
this.client = new DeliveryClient({
projectId: Config.KONTENT_PROJECT_ID,
// Use preview API for draft content in staging builds
previewApiKey: __DEV__ ? Config.KONTENT_PREVIEW_API_KEY : undefined,
defaultQueryConfig: {
usePreviewMode: __DEV__,
},
});
}
async loadArticles(category?: string) {
let query = this.client
.items<Article>()
.type('article')
.orderByDescending('elements.publish_date')
.limitParameter(30);
if (category) {
query = query.containsFilter('elements.category', [category]);
}
const response = await query.toPromise();
return response.data.items;
}
// Image optimization for mobile
getOptimizedImageUrl(assetUrl: string, width: number): string {
// Kontent.ai supports image transformations via URL parameters
return `${assetUrl}?w=${width}&fm=webp&q=80`;
}
}
What Works Well
Content Modeling: The content modeling interface is intuitive for building complex content structures. The concept of “content types” and “modular content” makes sense to non-technical users.
Workflow and Governance: Built-in workflow states, scheduled publishing, and content approval processes work well for enterprise teams.
TypeScript Support: The SDK has excellent TypeScript support with automatic type generation.
Content Variants: Multi-language support is first-class, not an afterthought.
Gotchas I’ve Encountered
Learning Curve: The terminology (snippets, modular content, content types) takes time to learn. It’s more complex than simpler CMSs.
SDK Size: The delivery SDK is larger than competitors, which matters for mobile apps. Consider code splitting:
// Lazy load Kontent SDK in React Native
const KontentService = React.lazy(() => import('./services/kontent'));
// Or use dynamic imports for specific functions
async function getContent() {
const { DeliveryClient } = await import('@kentico/kontent-delivery');
// Use client...
}
Image Management: Built-in image transformations are basic. For advanced image optimization, you’ll still want Cloudinary integration.
When to Choose Kontent
- You have complex content models with many relationships
- Multi-language content is a primary requirement
- Content workflow and governance are important
- Your team values strong TypeScript support
Storyblok: The Visual Editor Champion
Storyblok’s differentiator is its visual editor. Content editors see changes in real-time within the actual website/app design. This changes how non-technical teams work with content.
Component-Based Architecture
// Storyblok content type definition (components)
// In Storyblok, everything is a component
import { StoryblokComponent } from 'storyblok-js-client';
interface HeroComponent extends StoryblokComponent<'hero'> {
headline: string;
subheadline: string;
background_image: {
filename: string;
alt: string;
};
cta_button: {
label: string;
link: {
url: string;
};
};
}
interface ArticleComponent extends StoryblokComponent<'article'> {
title: string;
slug: string;
content: any; // Rich text JSON
featured_image: {
filename: string;
alt: string;
};
body: StoryblokComponent[]; // Nested components
}
// Next.js integration with live preview
import StoryblokClient from 'storyblok-js-client';
const Storyblok = new StoryblokClient({
accessToken: process.env.STORYBLOK_ACCESS_TOKEN!,
cache: {
clear: 'auto',
type: 'memory',
},
});
async function getStory(slug: string) {
const { data } = await Storyblok.get(`cdn/stories/${slug}`, {
version: process.env.NODE_ENV === 'development' ? 'draft' : 'published',
resolve_relations: ['article.author', 'article.category'],
});
return data.story;
}
// Visual editor bridge for live preview
import { useEffect } from 'react';
import { useStoryblokBridge, StoryblokComponent } from '@storyblok/react';
export default function ArticlePage({ story }) {
const [liveStory, setLiveStory] = React.useState(story);
// Enable live preview in Storyblok editor
useStoryblokBridge(liveStory.id, (updatedStory) => {
setLiveStory(updatedStory);
});
return <StoryblokComponent blok={liveStory.content} />;
}
React Native Integration with Visual Preview
Here’s where Storyblok gets interesting for mobile development:
// React Native app with Storyblok
import StoryblokClient from 'storyblok-js-client';
import { WebView } from 'react-native-webview';
class StoryblokMobileClient {
private client: StoryblokClient;
constructor() {
this.client = new StoryblokClient({
accessToken: Config.STORYBLOK_TOKEN,
cache: {
clear: 'auto',
type: 'memory',
},
});
}
async getStories(folder = '') {
const { data } = await this.client.get('cdn/stories', {
starts_with: folder,
version: __DEV__ ? 'draft' : 'published',
cv: Date.now(), // Cache versioning
});
return data.stories;
}
async getStory(slug: string) {
const { data } = await this.client.get(`cdn/stories/${slug}`, {
version: __DEV__ ? 'draft' : 'published',
});
return data.story;
}
// Image optimization using Storyblok's image service
optimizeImage(imageUrl: string, options: {
width?: number;
height?: number;
quality?: number;
}) {
const params = new URLSearchParams();
if (options.width) params.append('m', `${options.width}x0`);
if (options.quality) params.append('q', options.quality.toString());
return `${imageUrl}/m/${params.toString()}`;
}
}
// Component renderer for React Native
const ComponentRenderer = ({ blok }) => {
switch (blok.component) {
case 'hero':
return <HeroComponent data={blok} />;
case 'article':
return <ArticleComponent data={blok} />;
case 'text_block':
return <TextBlockComponent data={blok} />;
default:
console.warn(`Component ${blok.component} not implemented`);
return null;
}
};
function StoryblokStory({ story }) {
return (
<ScrollView>
{story.content.body.map((blok) => (
<ComponentRenderer key={blok._uid} blok={blok} />
))}
</ScrollView>
);
}
Live Preview Architecture
What Works Well
Visual Editing Experience: Content editors see exactly what they’re building. This reduces back-and-forth between content and development teams significantly.
Component-Based Content: The nested component approach maps well to modern frontend frameworks. Your UI components can directly match CMS components.
Field Plugins: You can build custom field types for specialized content needs.
Asset Management: The built-in image service handles basic transformations, though you might still want Cloudinary for advanced use cases.
Gotchas I’ve Encountered
Component Mapping Overhead: You need to maintain a registry mapping CMS components to your UI components. This adds development overhead:
// Component registry becomes a maintenance burden
const componentMap = {
'hero': HeroComponent,
'article': ArticleComponent,
'text_block': TextBlockComponent,
'image_gallery': ImageGalleryComponent,
'video_embed': VideoEmbedComponent,
'call_to_action': CTAComponent,
// ... 50+ components
};
// Version mismatches between CMS and code can break rendering
function renderComponent(blok: StoryblokComponent) {
const Component = componentMap[blok.component];
if (!Component) {
// Handle missing components gracefully
console.error(`Missing component: ${blok.component}`);
return <MissingComponentFallback blok={blok} />;
}
return <Component {...blok} />;
}
Mobile Preview Limitations: Live preview works great for web but requires workarounds for native mobile apps. You’ll likely need a web-based preview mode or deep linking setup.
Learning Curve for Editors: The component-based approach is powerful but requires training. Editors need to understand the component hierarchy.
When to Choose Storyblok
- Visual editing experience is a priority for your content team
- You’re building component-based UIs (React, Vue, etc.)
- Real-time preview during editing is valuable
- You have the development capacity to maintain component mappings
Image Management: The Cloudinary Factor
Regardless of which CMS you choose, you’ll likely need a dedicated Digital Asset Management (DAM) system for images and videos. Here’s how Cloudinary integrates with each CMS.
Why Separate Image Management?
Most CMS platforms have basic image storage, but at scale you need:
- Dynamic transformations: Resize, crop, format conversion on-the-fly
- Responsive images: Automatic srcset generation
- Optimization: Automatic format selection (WebP, AVIF), quality optimization
- CDN delivery: Global edge caching
- Video handling: Transcoding, adaptive bitrate streaming
Cloudinary Integration Patterns
// Cloudinary with any CMS
import { Cloudinary } from '@cloudinary/url-gen';
import { fill } from '@cloudinary/url-gen/actions/resize';
import { autoGravity } from '@cloudinary/url-gen/qualifiers/gravity';
const cld = new Cloudinary({
cloud: {
cloudName: process.env.CLOUDINARY_CLOUD_NAME,
},
url: {
secure: true,
},
});
// Upload image from CMS to Cloudinary
async function uploadToCloudinary(imageUrl: string, publicId: string) {
const formData = new FormData();
formData.append('file', imageUrl);
formData.append('upload_preset', process.env.CLOUDINARY_UPLOAD_PRESET!);
formData.append('public_id', publicId);
const response = await fetch(
`https://api.cloudinary.com/v1_1/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload`,
{
method: 'POST',
body: formData,
}
);
return response.json();
}
// Generate responsive image URLs
function getResponsiveImageUrls(publicId: string) {
const image = cld.image(publicId);
return {
mobile: image
.resize(fill().width(640).height(480).gravity(autoGravity()))
.format('auto')
.quality('auto')
.toURL(),
tablet: image
.resize(fill().width(1024).height(768).gravity(autoGravity()))
.format('auto')
.quality('auto')
.toURL(),
desktop: image
.resize(fill().width(1920).height(1080).gravity(autoGravity()))
.format('auto')
.quality('auto')
.toURL(),
};
}
// React Native optimized images
function getMobileImageUrl(publicId: string, width: number) {
const image = cld.image(publicId);
return image
.resize(fill().width(width))
.format('auto') // Cloudinary automatically selects WebP or JPEG
.quality('auto:low') // Optimize for mobile bandwidth
.toURL();
}
// Next.js Image component with Cloudinary
import Image from 'next/image';
function CloudinaryImage({ publicId, alt, width, height }) {
const cloudinaryLoader = ({ src, width, quality }) => {
const image = cld.image(src);
return image
.resize(fill().width(width))
.quality(quality || 'auto')
.format('auto')
.toURL();
};
return (
<Image
loader={cloudinaryLoader}
src={publicId}
alt={alt}
width={width}
height={height}
/>
);
}
Integration with Different CMS Platforms
// Strapi + Cloudinary plugin integration
// The official plugin handles uploads automatically
// Contentful + Cloudinary
// Store Cloudinary URLs in Contentful text fields
interface ContentfulArticleWithCloudinary {
title: string;
content: Document;
featuredImageCloudinaryId: string; // Store Cloudinary public ID
}
async function getArticleWithOptimizedImage(slug: string) {
const article = await getArticle(slug);
return {
...article,
featuredImage: {
mobile: getMobileImageUrl(article.featuredImageCloudinaryId, 640),
tablet: getMobileImageUrl(article.featuredImageCloudinaryId, 1024),
desktop: getMobileImageUrl(article.featuredImageCloudinaryId, 1920),
},
};
}
// Storyblok + Cloudinary
// Use Storyblok field plugin or store Cloudinary URLs
interface StoryblokImageField {
cloudinary_id: string;
alt: string;
}
function StoryblokCloudinaryImage({ field }: { field: StoryblokImageField }) {
const imageUrl = getMobileImageUrl(field.cloudinary_id, 1200);
return <img src={imageUrl} alt={field.alt} loading="lazy" />;
}
Cost Considerations
Cloudinary pricing is based on transformations, storage, and bandwidth. Here’s what impacts costs:
- Transformations: Each unique image transformation counts. Use fewer breakpoints to reduce costs.
- Storage: Original images and cached transformations count toward storage limits.
- Bandwidth: Delivery bandwidth, especially video, can add up quickly.
A practical strategy:
// Limit transformation variations to control costs
const STANDARD_BREAKPOINTS = [640, 1024, 1920]; // Only 3 sizes
function generateResponsiveImages(publicId: string) {
return STANDARD_BREAKPOINTS.map(width => ({
width,
url: cld.image(publicId)
.resize(fill().width(width))
.format('auto')
.quality('auto')
.toURL(),
}));
}
// Cache transformation URLs to avoid regeneration
const imageCache = new Map<string, string>();
function getCachedImageUrl(publicId: string, width: number): string {
const cacheKey = `${publicId}_${width}`;
if (imageCache.has(cacheKey)) {
return imageCache.get(cacheKey)!;
}
const url = getMobileImageUrl(publicId, width);
imageCache.set(cacheKey, url);
return url;
}
Framework Compatibility and Integration Patterns
Different frameworks have different strengths when working with headless CMS. Here’s what works well for each.
Next.js: The Natural Fit
// Static generation with any CMS
export async function generateStaticParams() {
const articles = await fetchAllArticles();
return articles.map((article) => ({
slug: article.slug,
}));
}
// Incremental Static Regeneration
async function getArticle(slug: string) {
const res = await fetch(`${CMS_URL}/articles/${slug}`, {
next: { revalidate: 3600 }, // Revalidate every hour
});
return res.json();
}
// On-demand revalidation via webhook
// /app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const { slug, secret } = body;
// Validate webhook secret
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 });
}
// Revalidate the specific article page
revalidatePath(`/posts/${slug}`);
return Response.json({ revalidated: true });
}
React Native: The Mobile Challenge
// Offline-first architecture for mobile
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
class OfflineFirstCMS {
private cacheKey = 'cms_content';
async getContent<T>(
fetcher: () => Promise<T>,
cacheOptions = { ttl: 3600000 } // 1 hour default
): Promise<T> {
// Try cache first
const cached = await this.getFromCache<T>();
if (cached && !this.isCacheExpired(cached.timestamp, cacheOptions.ttl)) {
return cached.data;
}
// Check network connectivity
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
if (cached) return cached.data; // Return stale data
throw new Error('No network and no cache available');
}
// Fetch fresh data
try {
const data = await fetcher();
await this.saveToCache(data);
return data;
} catch (error) {
// Fallback to cache on error
if (cached) return cached.data;
throw error;
}
}
private async getFromCache<T>(): Promise<{ data: T; timestamp: number } | null> {
try {
const cached = await AsyncStorage.getItem(this.cacheKey);
return cached ? JSON.parse(cached) : null;
} catch {
return null;
}
}
private async saveToCache<T>(data: T): Promise<void> {
await AsyncStorage.setItem(
this.cacheKey,
JSON.stringify({ data, timestamp: Date.now() })
);
}
private isCacheExpired(timestamp: number, ttl: number): boolean {
return Date.now() - timestamp > ttl;
}
}
// Usage in React Native component
function ArticleList() {
const [articles, setArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const cms = new OfflineFirstCMS();
useEffect(() => {
cms.getContent(() => fetchArticlesFromCMS())
.then(setArticles)
.finally(() => setLoading(false));
}, []);
if (loading) return <LoadingSpinner />;
return (
<FlatList
data={articles}
renderItem={({ item }) => <ArticleCard article={item} />}
keyExtractor={(item) => item.id}
/>
);
}
Vue/Nuxt: Similar Patterns
// Nuxt 3 with composables
export const useCMSContent = <T>(fetcher: () => Promise<T>) => {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref(true);
const fetch = async () => {
try {
loading.value = true;
data.value = await fetcher();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
};
// Auto-fetch on mount
onMounted(() => fetch());
return { data, error, loading, refetch: fetch };
};
// Usage in component
const { data: article } = useCMSContent(() =>
getArticleBySlug(route.params.slug as string)
);
Architecture Patterns for Multi-Platform Delivery
Here’s a practical architecture for serving content to web and mobile from a single CMS:
Implementation: Unified Content API
// Shared content client for web and mobile
interface ContentClient {
getArticles(options?: QueryOptions): Promise<Article[]>;
getArticle(slug: string): Promise<Article>;
getCategories(): Promise<Category[]>;
}
// Web implementation with caching
class WebContentClient implements ContentClient {
async getArticles(options: QueryOptions = {}) {
const cacheKey = `articles_${JSON.stringify(options)}`;
// Try Next.js cache first
const cached = await getCachedData(cacheKey);
if (cached) return cached;
const articles = await fetchFromCMS(options);
await setCachedData(cacheKey, articles, 3600);
return articles;
}
async getArticle(slug: string) {
return fetchArticleFromCMS(slug);
}
async getCategories() {
// Categories change rarely, cache aggressively
return getCachedOrFetch('categories', fetchCategoriesFromCMS, 86400);
}
}
// Mobile implementation with offline support
class MobileContentClient implements ContentClient {
private offlineCache = new OfflineFirstCMS();
async getArticles(options: QueryOptions = {}) {
return this.offlineCache.getContent(
() => fetchFromCMS(options),
{ ttl: 1800000 } // 30 min cache for mobile
);
}
async getArticle(slug: string) {
return this.offlineCache.getContent(
() => fetchArticleFromCMS(slug),
{ ttl: 3600000 } // 1 hour cache for articles
);
}
async getCategories() {
return this.offlineCache.getContent(
fetchCategoriesFromCMS,
{ ttl: 86400000 } // 24 hour cache for categories
);
}
}
// Factory pattern for platform-specific clients
export function createContentClient(): ContentClient {
if (typeof window !== 'undefined' && 'ReactNativeWebView' in window) {
return new MobileContentClient();
}
return new WebContentClient();
}
Webhook-Based Cache Invalidation
// Centralized webhook handler for all CMS platforms
// /app/api/webhooks/cms/route.ts
import { revalidatePath } from 'next/cache';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
export async function POST(request: NextRequest) {
const signature = request.headers.get('x-webhook-signature');
const body = await request.json();
// Validate webhook signature (implementation varies by CMS)
if (!validateSignature(signature, body)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
// Handle different webhook events
switch (body.event) {
case 'entry.publish':
case 'entry.update':
await handleContentUpdate(body.data);
break;
case 'entry.delete':
await handleContentDelete(body.data);
break;
case 'asset.upload':
await handleAssetUpdate(body.data);
break;
}
return Response.json({ received: true });
}
async function handleContentUpdate(data: any) {
const { slug, type } = data;
// Invalidate Next.js cache
revalidatePath(`/posts/${slug}`);
revalidatePath('/posts'); // List page
// Invalidate Redis cache
await redis.del(`article:${slug}`);
await redis.del('articles:list');
// Notify mobile apps via push notification or polling flag
await notifyMobileApps({ type: 'content_update', slug });
}
async function notifyMobileApps(event: any) {
// Set a flag that mobile apps can poll
await redis.set('mobile:latest_update', Date.now());
await redis.publish('content_updates', JSON.stringify(event));
}
Practical Decision Framework
After working with these platforms across different projects, here’s how I approach the decision:
Start Here: Team and Requirements
If your content team needs visual editing above all else: Storyblok
- Best for: Marketing sites, landing pages, content-heavy applications
- Trade-off: Component mapping maintenance overhead
If you need enterprise reliability and support: Contentful
- Best for: Large organizations, multi-platform applications, mission-critical content
- Trade-off: Higher costs, especially at scale
If you have complex content models and workflows: Kontent
- Best for: Publishing companies, multi-language sites, content governance requirements
- Trade-off: Steeper learning curve, larger SDK
If you have DevOps capacity and need customization: Strapi
- Best for: Startups, custom requirements, cost-sensitive projects at scale
- Trade-off: Self-hosting maintenance, upgrade complexity
Cost Projection Matrix
Here’s a rough cost comparison for a typical application (10,000 monthly visitors, 1,000 content entries):
Strapi (Self-hosted):
- Infrastructure: $50-200/month (depends on hosting)
- Development: Higher initial setup cost
- Scaling: Very cost-effective at high volume
Contentful:
- Starting tier: $489/month (Team plan)
- Scales with: Content types, API calls, users
- Good value at mid-scale, expensive at high scale
Kontent:
- Starting tier: EUR600/month (Scale plan)
- Scales with: Users, content items, languages
- Competitive for multi-language projects
Storyblok:
- Starting tier: EUR299/month (Entry plan)
- Scales with: API calls, users, content entries
- Reasonable pricing for visual editing features
Add Cloudinary costs:
- Free tier: Limited transformations
- Paid: $89-249/month for typical usage
- Scales with: Storage, transformations, bandwidth
Technical Considerations
API Design Preference:
- GraphQL preferred: Contentful, Kontent (both have excellent GraphQL APIs)
- REST preferred: Strapi (customizable REST endpoints)
- Either works: Storyblok (good support for both)
Mobile App Priority:
- Offline-first important: Contentful (Sync API), Kontent (good SDK)
- Real-time preview needed: Storyblok (with workarounds)
- Custom mobile API: Strapi (full control over endpoints)
Framework Ecosystem:
- Next.js: All platforms have good support
- React Native: Contentful and Kontent have better mobile SDKs
- Vue/Nuxt: All platforms work well, Storyblok has dedicated Vue SDK
Common Pitfalls and Solutions
Pitfall 1: Over-fetching Data
Problem: Fetching entire content objects when you only need titles and slugs for a list view.
Solution: Use field selection and optimize queries:
// Bad: Fetching everything
const articles = await client.getEntries({ content_type: 'article' });
// Good: Select only needed fields
const articles = await client.getEntries({
content_type: 'article',
select: 'fields.title,fields.slug,fields.excerpt,sys.id',
limit: 20,
});
// Better: Different queries for list vs detail views
async function getArticlesList() {
return client.getEntries({
content_type: 'article',
select: 'fields.title,fields.slug,fields.excerpt,fields.publishDate',
order: '-fields.publishDate',
});
}
async function getArticleDetail(slug: string) {
return client.getEntries({
content_type: 'article',
'fields.slug': slug,
include: 2, // Full content with relations
});
}
Pitfall 2: Ignoring Rate Limits
Problem: Hitting API rate limits during build or high traffic.
Solution: Implement request batching and caching:
// Request deduplication for identical queries
const requestCache = new Map<string, Promise<any>>();
async function dedupedRequest<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
if (requestCache.has(key)) {
return requestCache.get(key)!;
}
const promise = fetcher().finally(() => {
// Clear from cache after completion
requestCache.delete(key);
});
requestCache.set(key, promise);
return promise;
}
// Usage
async function getArticle(slug: string) {
return dedupedRequest(`article:${slug}`, () =>
client.getEntries({ 'fields.slug': slug })
);
}
Pitfall 3: Not Planning for Content Migration
Problem: Vendor lock-in makes it hard to switch CMS platforms later.
Solution: Abstract your CMS client behind an interface:
// Platform-agnostic interface
interface CMSClient {
getContent<T>(type: string, options?: QueryOptions): Promise<T[]>;
getContentBySlug<T>(type: string, slug: string): Promise<T | null>;
getAsset(id: string): Promise<Asset>;
}
// Contentful implementation
class ContentfulClient implements CMSClient {
async getContent<T>(type: string, options?: QueryOptions) {
const entries = await contentfulClient.getEntries({
content_type: type,
...options,
});
return entries.items as T[];
}
// ... other methods
}
// Strapi implementation
class StrapiClient implements CMSClient {
async getContent<T>(type: string, options?: QueryOptions) {
const response = await fetch(`${STRAPI_URL}/api/${type}s?${buildQuery(options)}`);
const data = await response.json();
return data.data as T[];
}
// ... other methods
}
// Use dependency injection
const cmsClient: CMSClient = process.env.CMS_PROVIDER === 'contentful'
? new ContentfulClient()
: new StrapiClient();
Key Takeaways
Here’s what I’ve learned after implementing each of these platforms:
There’s no universal “best” headless CMS. Your choice depends on team structure, technical requirements, and budget constraints.
Visual editing comes at a development cost. Storyblok’s live preview is powerful, but you’ll spend time maintaining component mappings.
Image management is separate from content management. Plan for Cloudinary or similar DAM from the start, especially for mobile applications.
Mobile requires different strategies. Offline-first architecture, smaller payloads, and longer cache times are essential for good mobile experience.
Start simple, scale complexity. Begin with basic REST APIs before adding GraphQL. Use simpler content models before complex component hierarchies.
Cache aggressively, invalidate precisely. Implement caching at every layer, but use webhooks to invalidate exactly what changed.
Abstract your CMS client. Build a thin abstraction layer to make platform migration possible in the future.
The multi-channel CMS landscape continues to evolve. What matters most is choosing a platform that matches your team’s skills, your content workflow needs, and your technical architecture requirements. Start with clear requirements, prototype with your top choices, and make a decision based on real usage, not marketing materials.
Related posts
A comprehensive guide to API versioning strategies covering URL vs header approaches, breaking changes, deprecation with Sunset headers, AWS API Gateway patterns, GraphQL evolution, and consumer-driven contract testing.
A practical introduction to idempotency for developers building APIs, payment flows, and message consumers. Covers HTTP method semantics, idempotency keys, database upserts, and common pitfalls with working Node.js examples.
Authentication vs authorization, common permission pitfalls, the fail-closed principle, and the goals every permission system should meet.
Refactor scattered permission checks into a centralized service layer, add Next.js middleware guards, and build a defense-in-depth authorization architecture.
Build a type-safe RBAC system with TypeScript, create a unified can() function, synchronize permissions across UI and backend, and understand when RBAC reaches its limits.