Skip to content
~/sph.sh

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

typescript
// 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

typescript
// Next.js integration with Strapiimport 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 usageasync 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.

typescript
// Performance optimization: Custom service to reduce N+1 queries// /src/api/article/services/article.tsexport 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

typescript
// 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 cachingimport { 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 resolutionasync 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 alternativeconst 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

typescript
// Mobile app integration with offline supportimport { 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:

typescript
// Implement request batching to avoid rate limitsclass 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

typescript
// Kontent.ai SDK integrationimport { 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 fetchingasync 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 sortingasync 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

typescript
// Kontent excels at taxonomies for categorizationinterface 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 implementationimport { 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:

typescript
// Lazy load Kontent SDK in React Nativeconst KontentService = React.lazy(() => import('./services/kontent'));
// Or use dynamic imports for specific functionsasync 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

typescript
// Storyblok content type definition (components)// In Storyblok, everything is a componentimport { 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 previewimport 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 previewimport { 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:

typescript
// React Native app with Storyblokimport 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 Nativeconst 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:

typescript
// Component registry becomes a maintenance burdenconst 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 renderingfunction 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

typescript
// Cloudinary with any CMSimport { 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 Cloudinaryasync 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 URLsfunction 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 imagesfunction 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 Cloudinaryimport 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

typescript
// Strapi + Cloudinary plugin integration// The official plugin handles uploads automatically
// Contentful + Cloudinary// Store Cloudinary URLs in Contentful text fieldsinterface 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 URLsinterface 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:

typescript
// Limit transformation variations to control costsconst 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 regenerationconst 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

typescript
// Static generation with any CMSexport async function generateStaticParams() {  const articles = await fetchAllArticles();
  return articles.map((article) => ({    slug: article.slug,  }));}
// Incremental Static Regenerationasync 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.tsimport { 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

typescript
// Offline-first architecture for mobileimport 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 componentfunction 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

typescript
// Nuxt 3 with composablesexport 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 componentconst { 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

typescript
// Shared content client for web and mobileinterface ContentClient {  getArticles(options?: QueryOptions): Promise<Article[]>;  getArticle(slug: string): Promise<Article>;  getCategories(): Promise<Category[]>;}
// Web implementation with cachingclass 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 supportclass 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 clientsexport function createContentClient(): ContentClient {  if (typeof window !== 'undefined' && 'ReactNativeWebView' in window) {    return new MobileContentClient();  }  return new WebContentClient();}

Webhook-Based Cache Invalidation

typescript
// Centralized webhook handler for all CMS platforms// /app/api/webhooks/cms/route.tsimport { 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:

typescript
// Bad: Fetching everythingconst articles = await client.getEntries({ content_type: 'article' });
// Good: Select only needed fieldsconst 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 viewsasync 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:

typescript
// Request deduplication for identical queriesconst 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;}
// Usageasync 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:

typescript
// Platform-agnostic interfaceinterface CMSClient {  getContent<T>(type: string, options?: QueryOptions): Promise<T[]>;  getContentBySlug<T>(type: string, slug: string): Promise<T | null>;  getAsset(id: string): Promise<Asset>;}
// Contentful implementationclass 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 implementationclass 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 injectionconst 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