Strapi vs Contentful vs Storyblok vs Kontent.ai: Headless CMS Karşılaştırması
Headless CMS çözümlerinin (Strapi, Contentful, Kontent, Storyblok) pratik karşılaştırması: Cloudinary ile görsel yönetimi ve framework entegrasyonu.
Headless CMS seçimi, tek bir içerik setinin hem web sitesini hem mobil uygulamayı hem de sıradaki kanalı beslemesi gerektiği anda zorlaşıyor. Kısa listeye genelde dört platform giriyor: Strapi, Contentful, Kontent.ai ve Storyblok. Depolayabildikleri içerik açısından birbirlerine yakınlar; asıl ayrıştıkları yer, kullanım maliyetini kimin üstlendiği. Karar da tek bir soruya iniyor: darboğaz içerik ekibiniz mi, geliştirme ekibiniz mi?
Çoğu ekip için varsayılan, yönetilen bir SaaS. Aynı içeriği birden fazla platform tüketiyorsa ve güvenilirlik esneklikten önemliyse Contentful uygun. Editörlerin değişikliği sayfanın içinde görmesi gerekiyorsa Storyblok uygun. Strapi’yi self-host etmek ise API hacmi, derin özelleştirme veya veri yerleşimi zorunluluk haline geldiğinde bakım maliyetini hak ediyor. Kontent.ai de asıl sorun çok dilli içerik yönetişimi olduğunda yerini buluyor. Görsel yönetimi dördünden de ayrı bir karar, çünkü trafik büyüdüğünde hepsi ayrı bir asset pipeline’ı istiyor.
Çok Kanallı CMS Ortamı#
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.
Headless mimariye geçmek şunları mümkün kılıyor: pazarlama ekibi deployment beklemeden içeriği güncelliyor, mobil uygulamalar web ile aynı içerik kaynağını kullanıyor ve ekipler içerik migrasyonu yapmadan farklı frontend framework’leriyle deney yapabiliyor. Beraberinde yeni zorluklar da geliyor: 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 kurma ihtiyacı.
Ç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
Seçim Kriterleri#
Pratikte bu platformları birbirinden ayıran beş boyut var:
API tasarımı GraphQL ile REST arasında ayrışıyor. Contentful ve Kontent.ai’nin ikisinin de sunduğu GraphQL, tek çağrıda tam olarak ihtiyacınız olan alanları istemenizi sağlıyor. REST aynı veriyi çekmek için daha fazla endpoint istiyor ve kavramsal olarak basit kalıyor. Editörleme deneyimi görsel feedback’e dayanıyor: teknik olmayan kullanıcılar bir değişikliğin yayında olduğuna güvenmeden önce onu görmek istiyor, Storyblok’un tam olarak kurulu olduğu eksen bu; developer’lar ise genelde schema-first bir formla rahat. Framework’ünüz için first-class bir SDK haftalarca entegrasyon işini kurtarıyor. Deployment modeli platformu kimin yamalayıp ölçekleyeceğini belirliyor: self-host ediyorsanız siz, etmiyorsanız sağlayıcı. Fiyatlandırma yapısının riski ölçüm biriminde saklı; bir birimin ne zaman faturalandığı çoğu zaman ölçeklenmeden önce net değil.
Strapi’yi Self-Host Etmek#
Strapi, size tam kontrol veren açık kaynak seçenek. Siz host ediyorsunuz, veri sizin, her şeyi özelleştirebiliyorsunuz.
Content Type Tanımlamak#
// 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"
}
}
}
Next.js ve React Native’den Veri Çekmek#
// 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();
}
Self-Hosting Nerede Kazandırıyor#
Admin panel’den API response’larına kadar her şeyi değiştirebiliyorsunuz; custom authentication flow’ları, legacy sistem entegrasyonları ve özelleştirilmiş içerik modelleri platform sınırlarına takılmadan mümkün. Dakikada binlerce API request’i karşılıyorsanız self-hosting, SaaS fiyatlandırmasından çok daha ucuza geliyor.
Medya, Upgrade ve Query Maliyeti#
Strapi’nin built-in media library’si küçük siteler için çalışıyor ama default upload handling yüksek trafikli siteler için production-ready değil. Major version upgrade’ler de acı verici olabiliyor: Strapi v3’ten v4’e geçiş önemli kod değişiklikleri gerektirdi, o yüzden migration için zaman planlayın. Kutusundan çıktığı haliyle database query’leri de optimize değil; caching eklemeniz, relation’ları optimize etmeniz ve bazen custom database query’leri yazmanız gerekiyor.
// 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;
},
}));
Kurumsal Kurulumda Contentful#
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];
}
Offline Cache’li Mobil Client#
// 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(),
}));
}
}
Rich Text, GraphQL ve Sync API#
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 API de iyi tasarlanmış; tam olarak ihtiyacınız olanı isteyebiliyorsunuz, bu da bandwidth kısıtlı mobil uygulamalar için önemli. Sync API offline-first mobil uygulamalar oluşturmak için mükemmel, içerik değişikliklerini incremental olarak sync ediyor; Preview API ise editörlerin unpublished içeriği gerçek uygulamanızda görmesini sağlıyor, content ekiplerinin dayandığı nokta burası.
Fiyatlandırma ve Rate Limit’ler#
Content type’ları, locale’ler ve API call’larına dayalı fiyatlandırma modeli pahalı olabiliyor; içerik hacmi arttığında aylık maliyetler beklenmedik şekilde yükselebiliyor. Rate limit’ler ayrı bir kısıt getiriyor: Contentful saniye başına request ölçüyor ve tavan, delivery API, preview API ve management API için farklı. Yüzlerce entry’ye yayılan bir build bu tavanı mutlaka buluyor, o yüzden takılmadan önce batch’leyip cache’leyin:
// 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: 200ms'de 4 request, yani 20 req/s tavan.
// Planınızın izin verdiği sınırın altında kalmak için bekleme süresini artırın.
await new Promise(resolve => setTimeout(resolve, 200));
this.processing = false;
this.processQueue(); // Bir sonraki batch'i işle
}
}
Link resolution için include parametresi de büyük payload’lara yol açabilir, o yüzden neye ihtiyacınız olduğu konusunda açık olun.
Kontent.ai ve İçerik Yönetişimi#
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ış.
SDK Kurulumu ve Tipli Sorgular#
// 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`;
}
}
Modelleme Derinliği ve Bedeli#
Content modeling arayüzü karmaşık içerik yapılarını rahat kaldırıyor; “content type” ve “modular content” kavramları teknik olmayan kullanıcılar için de anlaşılır. Workflow state’leri, scheduled publishing ve onay adımları hazır geliyor. Multi-language desteği first-class ve SDK otomatik TypeScript type generation ile geliyor.
Sürtünmenin kaynağı da aynı terminoloji. Snippet’ler, modular content ve content type’lar öğrenmek zaman alıyor; sistem daha basit bir CMS’ten daha karmaşık. Delivery SDK de rakiplerinden büyük, bu mobil uygulamalarda ö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...
}
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} />;
}
React Native’de Blokları Render Etmek#
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#
Visual Editor Neyi Değiştiriyor#
İçerik editörleri tam olarak ne oluşturduklarını görüyor, bu da content ve development ekipleri arasındaki gidip gelmeyi önemli ölçüde azaltıyor. Nested component yaklaşımı modern frontend framework’leriyle de iyi eşleşiyor: UI component’leriniz doğrudan CMS component’leriyle match edebilir. Özel field type’ları specialized içerik ihtiyaçlarını karşılıyor, built-in image service de basic transformation’ları hallediyor.
Component Registry Maliyeti#
CMS component’lerini UI component’lerinize map eden bir registry tutmanız gerekiyor, bu da 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} />;
}
Live preview web için harika çalışıyor ama native mobil uygulamalar için workaround’lar gerekiyor; genelde web-based preview mode ya da deep linking setup’ı gerekiyor. Component-based yaklaşımın kendisi de güçlü ama training gerektiriyor, çünkü editörlerin component hierarchy’sini anlaması gerekiyor.
Ayrı Bir Sistem Olarak Görseller#
Hangi CMS’i seçerseniz seçin, görseller ve videolar için ayrı bir Digital Asset Management (DAM) katmanına ihtiyacınız olacak. Ç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}
/>
);
}
Her CMS’e Bağlamak#
// 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" />;
}
Faturayı Ne Büyütüyor#
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 Entegrasyonu#
Farklı framework’ler headless CMS ile çalışırken farklı güçlü yönlere sahip.
Next.js#
// 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 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 ve Nuxt#
// 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)
);
Tek CMS ile Web ve Mobili Beslemek#
Pratik bir kurgu şöyle:
Paylaşılan Content Client#
// 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));
}
Aralarında Seçim Yapmak#
Açılıştaki uyum kriterleri kısa listeyi zaten daraltıyor. Geriye kalan soru, her platformun size gerçekte neye mal olduğu ve üzerine inşa ettiğinizde nasıl davrandığı.
Her Platform Neyi Faturalandırıyor#
Yayınlanan fiyat tarifeleri o kadar sık değişiyor ki burada verilecek her rakam siz okumadan eskir. Sabit kalan şey, faturalamanın hangi birim üzerinden yapıldığı; bir platformun sizin iş yükünüzde pahalıya gelip gelmeyeceğini de bu belirliyor.
Strapi (self-hosted): lisans ücreti yok. Sunucu, yönetilen bir veritabanı ve bunları ayakta tutacak mühendislik zamanı ödüyorsunuz. Trafik büyüdükçe bu maliyet kabaca sabit kalıyor; yüksek API hacminde öne çıkmasının, küçük bir sitede geride kalmasının sebebi bu.
Contentful: content type, API call, kullanıcı ve locale üzerinden ölçüyor. Orta ölçekte öngörülebilir; çok dilli projelerde sürprizi locale çarpanı yapıyor.
Kontent.ai: self-servis tarife yayınlamak yerine proje bazında teklif veriyor; hesap kullanıcı, content item ve dil sayısına göre çıkıyor.
Storyblok: API call, kullanıcı ve content entry üzerinden ölçüyor; tek bir küçük site için gerçekten kullanılabilir bir ücretsiz katmanı var.
Cloudinary: kredi üzerinden faturalandırıyor. Bir kredi kabaca 1.000 transformation, 1 GB yönetilen storage veya 1 GB video bandwidth’e karşılık geliyor. En hızlı büyüyen kalem video bandwidth’i.
API, Mobil ve Framework Uyumu#
API tasarım tercihi de dört platformu ayırıyor: Contentful ve Kontent’in ikisi de mükemmel GraphQL API’lere sahip, Strapi’nin REST endpoint’leri dördü arasında en özelleştirilebilir olanı, Storyblok ise ikisini de yeterince iyi destekliyor ki tercih çoğu zaman fark etmiyor.
Mobil öncelikler platformları bir kez daha ayırıyor. Offline-first uygulamalar için en iyi seçenek Contentful’ın Sync API’si veya Kontent’in SDK’sı; Storyblok real-time preview’u workaround’larla sağlıyor; Strapi’nin avantajı endpoint’ler üzerinde tam kontrol.
Framework desteğinde fark neredeyse yok denecek kadar az. Next.js’te dördü de iyi çalışıyor, Vue/Nuxt’ta da öyle; Storyblok burada ayrıca dedicated bir SDK sunuyor. React Native’de tablo değişiyor: Contentful ve Kontent, diğer ikisinden daha güçlü mobil SDK’lere sahip.
Tekrar Eden Entegrasyon Hataları#
Veriyi Aşırı Fetch Etmek#
Liste görünümlerinde başlık ve slug yeterken çoğu zaman tüm içerik objesi çekiliyor. Field selection kullanın ve liste sorgusunu detay sorgusundan ayırın:
// 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
});
}
Rate Limit’leri Göz Ardı Etmek#
Limitler kendini build sırasında ve trafik zirvelerinde gösteriyor. Batch’leyin, cache’leyin ve aynı anda giden özdeş sorguları tekilleştirin:
// 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 })
);
}
Migrasyon Planının Olmaması#
Vendor lock-in, sonradan platform değiştirmeyi pahalı hale getiriyor. CMS client’ını 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();
Varsayılan Seçim ve Sınırları#
Yönetilen SaaS varsayılanı, API hacminiz yayınlanan bir tarifenin içinde kaldığı ve içerik modeliniz sağlayıcının arayüzüyle ifade edilebildiği sürece geçerli. Ölçülen API call’ları faturanın en büyük kalemi haline geldiyse, veri yerleşimi yasal bir zorunluluksa veya içerik modeli hiçbir SaaS’ın açmadığı bir davranış istiyorsa Strapi’yi self-host edin. Asıl çözmeye çalıştığınız sorun çok dilli içerik yönetişimiyse Kontent.ai’ye bakın.
Hangi yolu seçerseniz seçin, asset kütüphanesi darboğaza dönüşmeden önce CMS’in önüne Cloudinary ya da dengi bir servis koyun ve CMS’i yukarıdaki gibi ince bir interface’in arkasında tutun; böylece bir sonraki migrasyon tek bir modülün içinde kalır.
Kaynaklar#
- Contentful Geliştirici Dokümantasyonu (yeni sekmede açılır) - İçerik modelleme, teslim API’leri, webhook’lar ve çerçeve entegrasyonlarını kapsayan resmi Contentful dokümantasyonu.
- Strapi 5 Dokümantasyonu (yeni sekmede açılır) - Kurulum, içerik modelleme, API yapılandırması ve arka uç özelleştirmesi için resmi Strapi CMS dokümantasyonu.
- Storyblok Dokümantasyonu (yeni sekmede açılır) - Görsel düzenleyici, bileşen tabanlı içerik ve Next.js ile React dahil çerçeve entegrasyonlarını kapsayan resmi Storyblok dokümantasyonu.
- Jamstack Nedir? (yeni sekmede açılır) - Headless CMS benimsemesinin ve çok kanallı teslimatın temelini oluşturan resmi Jamstack.org tanımı ve mimari ilkeleri.
- Storyblok API Genel Bakışı (yeni sekmede açılır) - Çok kanallı API tasarım kalıplarını anlamak için kullanışlı, Storyblok’un içerik teslim ve yönetim API’leri referansı.
- Contentful Fiyatlandırma (yeni sekmede açılır) - Contentful’ın yayınlanan plan katmanları ve fiyatlandırmanın ölçtüğü boyutlar: content type, kullanıcı ve locale sayısı.
- Storyblok Fiyatlandırma (yeni sekmede açılır) - Storyblok’un self-servis katmanları ve her birinin neyi ölçtüğü: API call, kullanıcı ve content entry sayısı.
- Kontent.ai Fiyatlandırma (yeni sekmede açılır) - Sabit genel katmanlar yerine kullanıcı, content item ve dil sayısına göre boyutlanan proje bazlı teklif modeli.
- Cloudinary Fiyatlandırma (yeni sekmede açılır) - Cloudinary faturalandırmasının arkasındaki kredi modeli; bir kredi transformation, yönetilen storage veya video bandwidth’i karşılıyor.
İlgili yazılar
WebView-native iletişim pattern'leri: mesaj geçişi, servis entegrasyonu ve çalışan kodla tipli bir request-response bridge'i.
AppSync subscription'ları yalnızca mutation ile tetiklenir. Downstream BFF olaylarını NONE veri kaynaklı bir mutation'a EventBridge ve CDK ile köprülemeyi inceliyorum.
Sunucu güdümlü arayüz, sunucu tarafı kompozisyonun mobil karşılığıdır. Zor kısmı JSON render etmek değil, eski sürümlerde ayakta kalan sürümlenmiş bileşen sözleşmesidir.
Mobil binary geri alınamaz ve eski sürümler kalıcıdır; güvenlik ve hız sunucuya kayar: BFF, tüketici güdümlü sözleşmeler ve geriye dönük uyumlu sürümleme.
Bir UI parçasının arkasındaki ince sunum servisi yapışkan koda dönüşür. Port-ve-adaptör, çekirdeği somut hiçbir şeye bağımlı bırakmayarak bunu sürdürülebilir tutar.