2025-10-26
Çok Kanallı İçerik Yönetimi: Headless CMS Dünyasında Yol Haritası
Headless CMS çözümlerinin pratik karşılaştırması - Strapi, Contentful, Kontent ve Storyblok - Cloudinary ile görsel yönetimi ve web ile mobil uygulamalar için framework entegrasyon pattern'leri.
Özet
Headless CMS seçmek eskiden basit görünüyordu - ta ki içeriğinizi aynı anda web, mobil ve potansiyel olarak IoT cihazlara servis etmeniz gerekene kadar. Farklı projelerde birkaç çok kanallı CMS çözümüyle çalıştıktan sonra öğrendim ki “en iyi” seçim ekip workflow’unuza, teknik kısıtlarınıza ve içerik editörleme deneyimi gereksinimlerinize bağlı. Bu rehber dört büyük oyuncuyu - Strapi, Contentful, Kontent ve Storyblok - karşılaştırıyor, görsel yönetimi, framework entegrasyonu ve önemli mimari kararlar hakkında pratik bilgiler sunuyor.
Çok Kanallı CMS Manzarası
WordPress gibi geleneksel içerik yönetim sistemleri içerik oluşturma, depolama ve sunum katmanlarını sıkı bir şekilde birbirine bağlar. Headless CMS çözümleri bu pattern’i kırarak içeriği API’ler üzerinden veri olarak sunar ve sunum katmanını istediğiniz şekilde oluşturmanıza izin verir.
İlk kez headless mimariye geçtiğimde şu değişiklikler oldu: pazarlama ekibi deployment beklemeden içeriği güncelleyebiliyordu, mobil uygulamalar web ile aynı içerik kaynağını kullanabiliyordu ve içerik migrasyonu yapmadan farklı frontend framework’leriyle deney yapabiliyorduk. Ama aynı zamanda yeni zorluklar da getirdi - trafik artışlarında API rate limit’leri, cache invalidation karmaşıklığı ve teknik olmayan kullanıcıların gerçekten kullanabileceği editörleme deneyimleri oluşturma ihtiyacı.
Çok Kanallı Dağıtım Neden Önemli
Çok kanallı CMS mimarisi, içerik yönetim katmanınızın uygulama sunucularınızdan bağımsız çalışması anlamına gelir. Bu ayrım şunları sağlar:
- Multi-channel delivery: Aynı içerik, farklı sunumlar (web, mobil, dijital tabela, vb.)
- Teknoloji esnekliği: İçeriğe dokunmadan React’i Vue ile değiştirebilirsiniz
- Bağımsız ölçeklendirme: Content API, uygulamanızdan ayrı ölçeklenebilir
- Ekip özerkliği: İçerik editörleri geliştirme döngülerinden bağımsız çalışır
Trade-off? Artık biri yerine iki sistem yönetiyorsunuz ve bununla gelen tüm karmaşıklık.
Değerlendirme Çerçevesi
Belirli çözümlere dalmadan önce, çok kanallı CMS seçerken neyin önemli olduğuna bakalım:
Önemli Karar Noktaları
API Tasarımı: GraphQL esnek sorgular sunar ama karmaşıklık ekler. REST daha basittir ama daha fazla endpoint gerektirir.
Editörleme Deneyimi: Teknik olmayan kullanıcılar görsel feedback’e ihtiyaç duyar. Developer’lar schema-first yaklaşımları tercih edebilir.
Framework Desteği: First-class SDK desteği haftalarca entegrasyon işini kurtarır.
Deployment Modeli: Self-hosted kontrol verir ama bakım gerektirir. SaaS daha hızlı başlatır ama daha az esneklik sunar.
Fiyatlandırma Yapısı: Ölçeklendikçe API call’ları, bandwidth veya storage’daki gizli maliyetlere dikkat edin.
Strapi: Self-Hosted Seçenek
Strapi, size tam kontrol veren açık kaynak seçenek. Siz host ediyorsunuz, veri sizin, her şeyi özelleştirebiliyorsunuz.
Mimari Genel Bakış
// Strapi content type tanımı
// /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 Entegrasyonu
// Next.js ile Strapi entegrasyonu
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 }, // 60 saniye cache
}
);
const data = await res.json();
return data.data;
}
// React Native kullanımı
async function fetchArticlesForMobile() {
const query = qs.stringify({
populate: ['coverImage'],
fields: ['title', 'excerpt', 'publishedAt'], // Mobil için daha hafif payload
pagination: {
pageSize: 20,
},
}, { encodeValuesOnly: true });
const response = await fetch(
`${STRAPI_URL}/api/articles?${query}`,
{
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
}
);
return response.json();
}
Neleri İyi Yapıyor
Tam Kontrol: Her şeyi değiştirebilirsiniz - admin panel’den API response’larına kadar. Custom authentication flow’ları ekledim, legacy sistemlerle entegre ettim ve platform sınırlamalarına takılmadan içerik modelini özelleştirdim.
Ölçekte Maliyet-Etkili: Dakikada binlerce API request’i handle ediyorsanız, self-hosting SaaS fiyatlandırmasından çok daha ucuz oluyor.
Plugin Ekosistemi: Belirli bir servisle entegre olmanız mı gerekiyor? Muhtemelen bir plugin var ya da kendiniz yapabilirsiniz.
Karşılaştığım Gotcha’lar
Media Handling: Strapi’nin built-in media library’si küçük siteler için çalışır ama ölçekte Cloudinary gibi bir şeyle entegre etmek istersiniz. Default upload handling yüksek trafikli siteler için production-ready değil.
Upgrade Path: Major version upgrade’leri acı verici olabiliyor. Strapi v3’ten v4’e geçiş önemli kod değişiklikleri gerektirdi. Migration zamanı planlayın.
Performance Tuning: Kutusundan çıktığı haliyle Strapi’nin database query’leri optimize değil. Caching eklemeniz, relation’ları optimize etmeniz ve potansiyel olarak custom database query’leri yazmanız gerekecek.
// Performance optimizasyonu: N+1 query'leri azaltmak için custom service
// /src/api/article/services/article.ts
export default factories.createCoreService('api::article.article', ({ strapi }) => ({
async findWithOptimizedRelations(params) {
// Strapi'nin relation'ları otomatik populate etmesine izin vermek yerine,
// N+1 problemlerinden kaçınmak için raw query kullan
const articles = await strapi.db.query('api::article.article').findMany({
...params,
populate: {
category: true,
coverImage: {
select: ['url', 'formats'],
},
},
});
return articles;
},
}));
Strapi’yi Ne Zaman Seçmeli
- Self-hosted infrastructure yönetecek DevOps kapasiteniz var
- Extensive customization veya mevcut sistemlerle entegrasyon gerekiyor
- API call volume’ü SaaS fiyatlandırmasını prohibitive hale getiriyor
- Data sovereignty on-premise hosting gerektiriyor
Contentful: Enterprise Standardı
Contentful, güçlü API, kapsamlı dokümantasyon ve olgun tooling’le kurumsal oyuncu. Güvenilirlik ve destek gerektiğinde seçtiğiniz platform.
Content Modeling
// Contentful TypeScript type'ları (content model'den generate edilmiş)
import { Entry, Asset } from 'contentful';
interface ArticleFields {
title: string;
slug: string;
content: Document; // Structured content olarak rich text
featuredImage: Asset;
category: Entry<CategoryFields>;
tags: string[];
publishDate: string;
author: Entry<AuthorFields>;
}
type Article = Entry<ArticleFields>;
// Caching ile client setup
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',
});
// Link resolution ile fetch
async function getArticle(slug: string): Promise<Article | null> {
const entries = await client.getEntries<ArticleFields>({
content_type: 'article',
'fields.slug': slug,
include: 2, // Link'leri 2 level derinlikte resolve et
limit: 1,
});
return entries.items[0] || null;
}
// GraphQL query alternatifi
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 Entegrasyonu
// Offline support ile mobil app entegrasyonu
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 dakika
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,
// Mobil için optimize: sadece gerekli field'ları fetch et
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(),
}));
}
}
Neleri İyi Yapıyor
Rich Text Handling: Contentful’ın structured rich text formatı güçlü. HTML string’ler yerine, web’de mobil’den farklı render edebileceğiniz structured bir document alıyorsunuz.
GraphQL Desteği: GraphQL API iyi tasarlanmış. Tam olarak ihtiyacınız olanı isteyebilirsiniz, bu bandwidth kısıtlı mobil uygulamalar için harika.
Webhook’lar ve Sync API: Sync API offline-first mobil uygulamalar oluşturmak için mükemmel. İçerik değişikliklerini incremental olarak sync edebilirsiniz.
Content Preview: Preview API, editörlerin unpublished içeriği gerçek uygulamanızda görmesini sağlar - content ekipleri için kritik.
Karşılaştığım Gotcha’lar
Fiyatlandırma Karmaşıklığı: Content type’ları, locale’ler ve API call’larına dayalı fiyatlandırma modeli pahalı olabiliyor. İçerik hacmi arttığında aylık maliyetlerin beklenmedik şekilde yükseldiğini gördüm.
Rate Limit’ler: Free tier agresif rate limit’lere sahip (saniyede 5 request). Production’da caching stratejilerine ihtiyacınız olacak:
// Rate limit'lerden kaçınmak için request batching implement et
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); // Batch başına 4 request
await Promise.all(batch.map(fn => fn()));
// Bir sonraki batch'ten önce 200ms bekle (5 request/saniye limiti)
await new Promise(resolve => setTimeout(resolve, 200));
this.processing = false;
this.processQueue(); // Bir sonraki batch'i işle
}
}
Link Resolution: Link resolution için include parametresi büyük payload’lara yol açabilir. Neye ihtiyacınız olduğu konusunda açık olun.
Contentful’ı Ne Zaman Seçmeli
- Enterprise-grade güvenilirlik ve destek gerekiyor
- Ekibiniz kapsamlı dokümantasyon ve community kaynaklarına değer veriyor
- Multi-platform uygulamalar oluşturuyorsunuz (web, mobil, IoT)
- GraphQL API tasarımı mimarinize uyuyor
Kontent.ai: Content Modeling Uzmanı
Kontent (eski adıyla Kentico Kontent) content modeling ve governance’a odaklanıyor. Karmaşık içerik yapıları ve birden fazla ekibi olan organizasyonlar için tasarlanmış.
Content Mimarisi
// Kontent.ai SDK entegrasyonu
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];
}
// Filtering ve sorting ile fetch
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 ve Content İlişkileri
// Kontent kategorileme için taxonomy'lerde mükemmel
interface TaxonomyTerm {
name: string;
codename: string;
}
async function getArticlesWithTaxonomy() {
const response = await deliveryClient
.items<Article>()
.type('article')
.toPromise();
// Article'lar tamamen resolve edilmiş taxonomy term'leriyle gelir
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 implementasyonu
import { DeliveryClient } from '@kentico/kontent-delivery';
class KontentMobileService {
private client: DeliveryClient;
constructor() {
this.client = new DeliveryClient({
projectId: Config.KONTENT_PROJECT_ID,
// Staging build'lerde draft content için preview API kullan
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;
}
// Mobil için image optimizasyonu
getOptimizedImageUrl(assetUrl: string, width: number): string {
// Kontent.ai URL parametreleri ile image transformation'ları destekliyor
return `${assetUrl}?w=${width}&fm=webp&q=80`;
}
}
Neleri İyi Yapıyor
Content Modeling: Content modeling arayüzü karmaşık içerik yapıları oluşturmak için sezgisel. “Content type’ları” ve “modular content” kavramı teknik olmayan kullanıcılar için mantıklı.
Workflow ve Governance: Built-in workflow state’leri, scheduled publishing ve content approval süreçleri enterprise ekipleri için iyi çalışıyor.
TypeScript Desteği: SDK mükemmel TypeScript desteğine sahip, otomatik type generation ile.
Content Variant’ları: Multi-language desteği first-class, sonradan eklenen bir şey değil.
Karşılaştığım Gotcha’lar
Öğrenme Eğrisi: Terminoloji (snippet’ler, modular content, content type’lar) öğrenmek zaman alıyor. Daha basit CMS’lerden daha karmaşık.
SDK Boyutu: Delivery SDK rakiplerinden daha büyük, bu mobil uygulamalar için önemli. Code splitting düşünün:
// React Native'de Kontent SDK'yı lazy load et
const KontentService = React.lazy(() => import('./services/kontent'));
// Ya da belirli fonksiyonlar için dynamic import kullan
async function getContent() {
const { DeliveryClient } = await import('@kentico/kontent-delivery');
// Client'ı kullan...
}
Image Management: Built-in image transformation’ları basic. Advanced image optimization için yine Cloudinary entegrasyonu isteyeceksiniz.
Kontent’i Ne Zaman Seçmeli
- Çok ilişkili karmaşık content model’leriniz var
- Multi-language content birincil gereksinim
- Content workflow ve governance önemli
- Ekibiniz güçlü TypeScript desteğine değer veriyor
Storyblok: Visual Editor Şampiyonu
Storyblok’un farklılaştırıcısı visual editor’ü. İçerik editörleri değişiklikleri gerçek website/app tasarımı içinde real-time olarak görüyor. Bu, teknik olmayan ekiplerin içerikle çalışma şeklini değiştiriyor.
Component-Based Mimari
// Storyblok content type tanımı (component'ler)
// Storyblok'ta her şey bir 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 component'ler
}
// Live preview ile Next.js entegrasyonu
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;
}
// Live preview için visual editor bridge
import { useEffect } from 'react';
import { useStoryblokBridge, StoryblokComponent } from '@storyblok/react';
export default function ArticlePage({ story }) {
const [liveStory, setLiveStory] = React.useState(story);
// Storyblok editor'de live preview'i etkinleştir
useStoryblokBridge(liveStory.id, (updatedStory) => {
setLiveStory(updatedStory);
});
return <StoryblokComponent blok={liveStory.content} />;
}
Visual Preview ile React Native Entegrasyonu
Storyblok’un mobil development için ilginç olduğu yer burası:
// Storyblok ile React Native app
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;
}
// Storyblok'un image service'ini kullanarak image optimizasyonu
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()}`;
}
}
// React Native için component renderer
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} implement edilmemiş`);
return null;
}
};
function StoryblokStory({ story }) {
return (
<ScrollView>
{story.content.body.map((blok) => (
<ComponentRenderer key={blok._uid} blok={blok} />
))}
</ScrollView>
);
}
Live Preview Mimarisi
Neleri İyi Yapıyor
Visual Editing Deneyimi: İçerik editörleri tam olarak ne oluşturduklarını görüyor. Bu, content ve development ekipleri arasındaki gidip gelmeyi önemli ölçüde azaltıyor.
Component-Based Content: Nested component yaklaşımı modern frontend framework’leriyle iyi eşleşiyor. UI component’leriniz doğrudan CMS component’leriyle match edebilir.
Field Plugin’leri: Specialized content ihtiyaçları için custom field type’ları oluşturabilirsiniz.
Asset Management: Built-in image service basic transformation’ları handle ediyor, ancak advanced use case’ler için yine Cloudinary isteyebilirsiniz.
Karşılaştığım Gotcha’lar
Component Mapping Overhead: CMS component’lerini UI component’lerinize map eden bir registry tutmanız gerekiyor. Bu development overhead ekliyor:
// Component registry bir bakım yükü haline geliyor
const componentMap = {
'hero': HeroComponent,
'article': ArticleComponent,
'text_block': TextBlockComponent,
'image_gallery': ImageGalleryComponent,
'video_embed': VideoEmbedComponent,
'call_to_action': CTAComponent,
// ... 50+ component
};
// CMS ve code arasındaki version uyumsuzlukları rendering'i bozabilir
function renderComponent(blok: StoryblokComponent) {
const Component = componentMap[blok.component];
if (!Component) {
// Eksik component'leri gracefully handle et
console.error(`Eksik component: ${blok.component}`);
return <MissingComponentFallback blok={blok} />;
}
return <Component {...blok} />;
}
Mobile Preview Sınırlamaları: Live preview web için harika çalışıyor ama native mobil uygulamalar için workaround’lar gerekiyor. Muhtemelen web-based preview mode veya deep linking setup’ına ihtiyacınız olacak.
Editörler İçin Öğrenme Eğrisi: Component-based yaklaşım güçlü ama training gerektiriyor. Editörlerin component hierarchy’sini anlaması gerekiyor.
Storyblok’u Ne Zaman Seçmeli
- Visual editing deneyimi content ekibiniz için öncelik
- Component-based UI’lar oluşturuyorsunuz (React, Vue, vb.)
- Editörleme sırasında real-time preview değerli
- Component mapping’leri maintain edecek development kapasiteniz var
Image Management: Cloudinary Faktörü
Hangi CMS’i seçerseniz seçin, muhtemelen image ve video’lar için dedicated bir Digital Asset Management (DAM) sistemine ihtiyacınız olacak. İşte Cloudinary’nin her CMS ile nasıl entegre olduğu.
Neden Ayrı Image Management?
Çoğu CMS platformu basic image storage’a sahip ama ölçekte şunlara ihtiyacınız var:
- Dynamic transformation’lar: On-the-fly resize, crop, format conversion
- Responsive image’lar: Otomatik srcset generation
- Optimization: Otomatik format seçimi (WebP, AVIF), kalite optimizasyonu
- CDN delivery: Global edge caching
- Video handling: Transcoding, adaptive bitrate streaming
Cloudinary Entegrasyon Pattern’leri
// Herhangi bir CMS ile Cloudinary
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,
},
});
// CMS'den Cloudinary'ye image upload
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();
}
// Responsive image URL'leri generate et
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 optimize edilmiş image'lar
function getMobileImageUrl(publicId: string, width: number) {
const image = cld.image(publicId);
return image
.resize(fill().width(width))
.format('auto') // Cloudinary otomatik olarak WebP veya JPEG seçiyor
.quality('auto:low') // Mobil bandwidth için optimize et
.toURL();
}
// Cloudinary ile Next.js Image component
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}
/>
);
}
Farklı CMS Platformlarıyla Entegrasyon
// Strapi + Cloudinary plugin entegrasyonu
// Resmi plugin upload'ları otomatik handle ediyor
// Contentful + Cloudinary
// Contentful text field'larında Cloudinary URL'lerini sakla
interface ContentfulArticleWithCloudinary {
title: string;
content: Document;
featuredImageCloudinaryId: string; // Cloudinary public ID'yi sakla
}
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
// Storyblok field plugin kullan ya da Cloudinary URL'lerini sakla
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" />;
}
Maliyet Değerlendirmeleri
Cloudinary fiyatlandırması transformation’lara, storage’a ve bandwidth’e dayanıyor. Maliyetleri etkileyen faktörler:
- Transformation’lar: Her unique image transformation sayılıyor. Maliyetleri azaltmak için daha az breakpoint kullanın.
- Storage: Orijinal image’lar ve cache’lenmiş transformation’lar storage limit’lerine sayılıyor.
- Bandwidth: Delivery bandwidth, özellikle video, hızla artabiliyor.
Pratik bir strateji:
// Maliyetleri kontrol etmek için transformation varyasyonlarını sınırla
const STANDARD_BREAKPOINTS = [640, 1024, 1920]; // Sadece 3 boyut
function generateResponsiveImages(publicId: string) {
return STANDARD_BREAKPOINTS.map(width => ({
width,
url: cld.image(publicId)
.resize(fill().width(width))
.format('auto')
.quality('auto')
.toURL(),
}));
}
// Regeneration'dan kaçınmak için transformation URL'lerini cache'le
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 Uyumluluğu ve Entegrasyon Pattern’leri
Farklı framework’ler headless CMS ile çalışırken farklı güçlü yönlere sahip. İşte her biri için iyi çalışan şeyler.
Next.js: Doğal Uyum
// Herhangi bir CMS ile static generation
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 }, // Her saat revalidate et
});
return res.json();
}
// Webhook ile on-demand revalidation
// /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;
// Webhook secret'ı validate et
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 });
}
// Belirli article sayfasını revalidate et
revalidatePath(`/posts/${slug}`);
return Response.json({ revalidated: true });
}
React Native: Mobil Zorluk
// Mobil için offline-first mimari
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 } // Default 1 saat
): Promise<T> {
// Önce cache'i dene
const cached = await this.getFromCache<T>();
if (cached && !this.isCacheExpired(cached.timestamp, cacheOptions.ttl)) {
return cached.data;
}
// Network bağlantısını kontrol et
const netInfo = await NetInfo.fetch();
if (!netInfo.isConnected) {
if (cached) return cached.data; // Stale data döndür
throw new Error('Network yok ve cache mevcut değil');
}
// Fresh data fetch et
try {
const data = await fetcher();
await this.saveToCache(data);
return data;
} catch (error) {
// Hata durumunda cache'e fallback
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;
}
}
// React Native component'te kullanım
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: Benzer Pattern’ler
// Composable'larla Nuxt 3
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;
}
};
// Mount'ta auto-fetch
onMounted(() => fetch());
return { data, error, loading, refetch: fetch };
};
// Component'te kullanım
const { data: article } = useCMSContent(() =>
getArticleBySlug(route.params.slug as string)
);
Multi-Platform Delivery için Mimari Pattern’ler
Tek bir CMS’den web ve mobil’e içerik sunmak için pratik bir mimari:
Implementation: Unified Content API
// Web ve mobil için paylaşılan content client
interface ContentClient {
getArticles(options?: QueryOptions): Promise<Article[]>;
getArticle(slug: string): Promise<Article>;
getCategories(): Promise<Category[]>;
}
// Caching ile web implementasyonu
class WebContentClient implements ContentClient {
async getArticles(options: QueryOptions = {}) {
const cacheKey = `articles_${JSON.stringify(options)}`;
// Önce Next.js cache'i dene
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() {
// Kategoriler nadiren değişiyor, agresif cache
return getCachedOrFetch('categories', fetchCategoriesFromCMS, 86400);
}
}
// Offline support ile mobil implementasyonu
class MobileContentClient implements ContentClient {
private offlineCache = new OfflineFirstCMS();
async getArticles(options: QueryOptions = {}) {
return this.offlineCache.getContent(
() => fetchFromCMS(options),
{ ttl: 1800000 } // Mobil için 30 dakika cache
);
}
async getArticle(slug: string) {
return this.offlineCache.getContent(
() => fetchArticleFromCMS(slug),
{ ttl: 3600000 } // Article'lar için 1 saat cache
);
}
async getCategories() {
return this.offlineCache.getContent(
fetchCategoriesFromCMS,
{ ttl: 86400000 } // Kategoriler için 24 saat cache
);
}
}
// Platform-specific client'lar için factory pattern
export function createContentClient(): ContentClient {
if (typeof window !== 'undefined' && 'ReactNativeWebView' in window) {
return new MobileContentClient();
}
return new WebContentClient();
}
Webhook-Based Cache Invalidation
// Tüm CMS platformları için merkezi webhook handler
// /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();
// Webhook signature'ı validate et (CMS'e göre değişir)
if (!validateSignature(signature, body)) {
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
// Farklı webhook event'leri handle et
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;
// Next.js cache'i invalidate et
revalidatePath(`/posts/${slug}`);
revalidatePath('/posts'); // Liste sayfası
// Redis cache'i invalidate et
await redis.del(`article:${slug}`);
await redis.del('articles:list');
// Push notification veya polling flag ile mobil app'leri bilgilendir
await notifyMobileApps({ type: 'content_update', slug });
}
async function notifyMobileApps(event: any) {
// Mobil app'lerin poll edebileceği bir flag set et
await redis.set('mobile:latest_update', Date.now());
await redis.publish('content_updates', JSON.stringify(event));
}
Pratik Karar Çerçevesi
Bu platformlarla farklı projelerde çalıştıktan sonra, karara nasıl yaklaşıyorum:
Buradan Başlayın: Ekip ve Gereksinimler
İçerik ekibiniz her şeyden önce visual editing’e ihtiyaç duyuyorsa: Storyblok
- En iyisi: Marketing siteleri, landing page’ler, content-heavy uygulamalar
- Trade-off: Component mapping bakım yükü
Enterprise güvenilirlik ve destek gerekiyorsa: Contentful
- En iyisi: Büyük organizasyonlar, multi-platform uygulamalar, mission-critical içerik
- Trade-off: Daha yüksek maliyetler, özellikle ölçekte
Karmaşık content model’leriniz ve workflow’larınız varsa: Kontent
- En iyisi: Publishing şirketleri, multi-language siteler, content governance gereksinimleri
- Trade-off: Daha dik öğrenme eğrisi, daha büyük SDK
DevOps kapasiteniz var ve customization gerekiyorsa: Strapi
- En iyisi: Startup’lar, custom gereksinimler, ölçekte maliyet-hassas projeler
- Trade-off: Self-hosting bakımı, upgrade karmaşıklığı
Maliyet Projeksiyon Matrisi
Tipik bir uygulama için kabaca maliyet karşılaştırması (aylık 10.000 ziyaretçi, 1.000 content entry):
Strapi (Self-hosted):
- Infrastructure: Aylık $50-200 (hosting’e bağlı)
- Development: Daha yüksek initial setup maliyeti
- Scaling: Yüksek volume’de çok maliyet-etkili
Contentful:
- Başlangıç tier: Aylık $489 (Team plan)
- Şunlarla ölçekliyor: Content type’ları, API call’ları, kullanıcılar
- Orta ölçekte iyi değer, yüksek ölçekte pahalı
Kontent:
- Başlangıç tier: Aylık EUR600 (Scale plan)
- Şunlarla ölçekliyor: Kullanıcılar, content item’ları, diller
- Multi-language projeler için rekabetçi
Storyblok:
- Başlangıç tier: Aylık EUR299 (Entry plan)
- Şunlarla ölçekliyor: API call’ları, kullanıcılar, content entry’leri
- Visual editing özellikleri için makul fiyatlandırma
Cloudinary maliyetlerini ekleyin:
- Free tier: Sınırlı transformation’lar
- Paid: Tipik kullanım için aylık $89-249
- Şunlarla ölçekliyor: Storage, transformation’lar, bandwidth
Teknik Değerlendirmeler
API Tasarım Tercihi:
- GraphQL tercih ediliyorsa: Contentful, Kontent (her ikisi de mükemmel GraphQL API’lere sahip)
- REST tercih ediliyorsa: Strapi (özelleştirilebilir REST endpoint’leri)
- Her ikisi de çalışıyor: Storyblok (her ikisi için iyi destek)
Mobil App Önceliği:
- Offline-first önemliyse: Contentful (Sync API), Kontent (iyi SDK)
- Real-time preview gerekiyorsa: Storyblok (workaround’larla)
- Custom mobile API: Strapi (endpoint’ler üzerinde tam kontrol)
Framework Ekosistemi:
- Next.js: Tüm platformlar iyi desteğe sahip
- React Native: Contentful ve Kontent daha iyi mobil SDK’lere sahip
- Vue/Nuxt: Tüm platformlar iyi çalışıyor, Storyblok dedicated Vue SDK’ye sahip
Yaygın Tuzaklar ve Çözümler
Tuzak 1: Veriyi Aşırı Fetch Etmek
Problem: Liste görünümü için sadece başlıklara ve slug’lara ihtiyacınız varken tüm içerik objelerini fetch etmek.
Çözüm: Field selection kullanın ve query’leri optimize edin:
// Kötü: Her şeyi fetch etmek
const articles = await client.getEntries({ content_type: 'article' });
// İyi: Sadece gerekli field'ları seç
const articles = await client.getEntries({
content_type: 'article',
select: 'fields.title,fields.slug,fields.excerpt,sys.id',
limit: 20,
});
// Daha iyi: Liste ve detay view'ları için farklı query'ler
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, // Relation'larla tam içerik
});
}
Tuzak 2: Rate Limit’leri Göz Ardı Etmek
Problem: Build veya yüksek trafik sırasında API rate limit’lerine takılmak.
Çözüm: Request batching ve caching implement edin:
// Identical query'ler için request deduplication
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(() => {
// Completion'dan sonra cache'den temizle
requestCache.delete(key);
});
requestCache.set(key, promise);
return promise;
}
// Kullanım
async function getArticle(slug: string) {
return dedupedRequest(`article:${slug}`, () =>
client.getEntries({ 'fields.slug': slug })
);
}
Tuzak 3: Content Migration İçin Planlamama
Problem: Vendor lock-in sonradan CMS platformlarını değiştirmeyi zorlaştırıyor.
Çözüm: CMS client’ınızı bir interface arkasında soyutlayın:
// 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 implementasyonu
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[];
}
// ... diğer metodlar
}
// Strapi implementasyonu
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[];
}
// ... diğer metodlar
}
// Dependency injection kullan
const cmsClient: CMSClient = process.env.CMS_PROVIDER === 'contentful'
? new ContentfulClient()
: new StrapiClient();
Önemli Çıkarımlar
Her bir platformu implement ettikten sonra öğrendiklerim:
Evrensel “en iyi” headless CMS yok. Seçiminiz ekip yapısına, teknik gereksinimlere ve bütçe kısıtlarına bağlı.
Visual editing bir development maliyeti ile geliyor. Storyblok’un live preview’i güçlü ama component mapping’leri maintain ederken zaman harcayacaksınız.
Image management, content management’dan ayrı. Özellikle mobil uygulamalar için baştan Cloudinary veya benzer DAM planlayın.
Mobil farklı stratejiler gerektiriyor. Offline-first mimari, daha küçük payload’lar ve daha uzun cache süreleri iyi mobil deneyim için esansiyel.
Basit başlayın, karmaşıklığı ölçeklendirin. GraphQL eklemeden önce basic REST API’leriyle başlayın. Karmaşık component hierarchy’lerinden önce daha basit content model’leri kullanın.
Agresif cache’leyin, precise invalidate edin. Her katmanda caching implement edin ama değişenleri tam olarak invalidate etmek için webhook’lar kullanın.
CMS client’ınızı soyutlayın. Gelecekte platform migrasyonunu mümkün kılmak için ince bir soyutlama katmanı oluşturun.
Çok kanallı CMS manzarası evrim geçirmeye devam ediyor. En önemli olan ekibinizin becerilerine, content workflow ihtiyaçlarınıza ve teknik mimari gereksinimlerinize uyan bir platform seçmek. Net gereksinimlerle başlayın, en iyi seçeneklerinizle prototip yapın ve pazarlama materyallerine değil gerçek kullanıma dayalı karar verin.
İlgili yazılar
URL ve header versioning yaklaşımları, breaking change yönetimi, Sunset header'ları ile deprecation, AWS API Gateway pattern'leri, GraphQL schema evolution ve consumer-driven contract testing'i kapsayan kapsamlı bir API versioning rehberi.
API, ödeme akışı ve mesaj tüketicisi geliştiren yazılımcılar için idempotency'ye pratik bir giriş. HTTP metot semantiği, idempotency key'leri, veritabanı upsert ve yaygın tuzakları çalışan Node.js örnekleriyle anlatır.
Authentication ve authorization farkı, yaygın izin sistemi tuzakları, fail-closed prensibi ve her izin sisteminin karşılaması gereken hedefler.
Dağınık izin kontrollerini merkezi bir service layer'a taşıyın, Next.js middleware guard'ları ekleyin ve derinlemesine savunma yetkilendirme mimarisi oluşturun.
TypeScript ile type-safe bir RBAC sistemi oluşturun, birleşik bir can() fonksiyonu yazın, UI ve backend'de izinleri senkronize edin ve RBAC'ın sınırlarını anlayın.