Skip to content

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:

CMS Selection Criteria

API Design

Editing Experience

Framework Support

Deployment Model

Pricing Structure

REST vs GraphQL

SDK Quality

Webhook Support

Visual Preview

Component Library

Learning Curve

Web Frameworks

Mobile Frameworks

Static Generation

Self-hosted

Managed SaaS

Hybrid Options

Content Volume

API Calls

User Seats

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

Webhook

Web

Mobile

Bridge API

Deep Link

Real-time Updates

Device Preview

Storyblok Editor

Preview API

Platform

Next.js App

React Native WebView

Native Preview

Visual Preview

Component Preview

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:

Mobile Platform

Web Platform

API Layer

Content Layer

Content

Content

Images

Images

Optimized Queries

Optimized Queries

Periodic Update

Headless CMS

Cloudinary DAM

GraphQL Gateway

REST API

Redis Cache

Next.js App

Static Pages

ISR Pages

React Native App

Local Storage

Background Sync

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