Headless CMS Architecture: Building Modern Content Infrastructure
Master headless CMS architecture patterns. Learn API design, content modeling, caching strategies, multi-channel delivery, and real-world implementation with Strapi, Contentful, and custom solutions.
Why Headless CMS?
Traditional monolithic CMS platforms (WordPress, Drupal) tightly couple content management with presentation. This creates limitations when delivering content across multiple channels—web, mobile apps, IoT devices, digital signage.
Headless CMS solves this by decoupling content (backend) from presentation (frontend), exposing content via APIs for any client to consume.
The business case:
- Omnichannel delivery: One content source → multiple frontends
- Developer freedom: Choose any framework (React, Vue, Next.js, mobile)
- Performance: Static site generation + CDN = sub-100ms page loads
- Scalability: API-first architecture handles millions of requests
- Future-proof: New channels? Just add another API consumer
Core Architecture Patterns
Content API Layer
The API is your contract with frontend clients. Design it carefully.
REST vs GraphQL:
// REST API (Strapi default)
GET /api/articles?populate=author,categories&filters[status][$eq]=published
// Response
{
"data": [
{
"id": 1,
"attributes": {
"title": "Docker Security Guide",
"slug": "docker-security-guide",
"content": "...",
"publishedAt": "2025-11-17T00:00:00Z",
"author": { "data": {...} },
"categories": { "data": [...] }
}
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 10, "total": 42 }
}
}
# GraphQL API (Contentful, GraphCMS)
query {
articleCollection(where: { status: "published" }) {
items {
title
slug
content
publishedAt
author {
name
avatar { url }
}
categoriesCollection {
items { name }
}
}
}
}
When to use each:
- REST: Simple CRUD, clear caching, easier debugging
- GraphQL: Complex relationships, mobile apps (reduce over-fetching), rapid iteration
Content Modeling Strategy
Structure content for reusability across channels.
Bad: Page-centric model (traditional CMS mindset)
BlogPost:
- title
- heroImage
- body (rich text with images embedded)
- sidebar
- author
Good: Component-based model (headless mindset)
Article:
- title (text)
- slug (text, unique)
- summary (text, 160 chars)
- heroImage (reference → Media)
- content (array of content blocks)
- author (reference → Author)
- categories (references → Category[])
- publishedAt (datetime)
- metadata (SEO fields)
ContentBlock (polymorphic):
- TextBlock: { content: richText }
- ImageBlock: { image: Media, caption: text, alt: text }
- CodeBlock: { code: text, language: text }
- VideoBlock: { videoUrl: text, provider: enum }
- CalloutBlock: { type: enum, content: text }
Why structured content blocks?
- Reusable across different layouts
- Easy to render on mobile (native components)
- Accessible (semantic markup)
- Testable (each block type is isolated)
Multi-Tenancy for Agencies
Serve multiple clients from one CMS instance:
// Strapi multi-tenant plugin
// strapi-server.js
module.exports = {
async initialize() {
// Add tenant context to all queries
strapi.db.lifecycles.subscribe({
models: ['*'],
beforeCreate(event) {
event.params.data.tenant = event.state.tenant;
},
beforeFindMany(event) {
event.params.filters = {
...event.params.filters,
tenant: event.state.tenant
};
}
});
}
};
// Extract tenant from request
app.use(async (ctx, next) => {
const tenant = ctx.request.headers['x-tenant-id'] ||
ctx.subdomain ||
extractFromJWT(ctx.request.headers.authorization);
ctx.state.tenant = tenant;
await next();
});
Benefits:
- Shared infrastructure, isolated data
- Per-tenant customization
- Centralized billing and monitoring
- Scales to thousands of clients
Caching Strategies
Content APIs are read-heavy. Caching is non-negotiable.
Application-Level Caching (Redis)
// Express middleware with Redis
const redis = require('redis');
const client = redis.createClient();
async function cacheMiddleware(req, res, next) {
const key = api:${req.path}:${JSON.stringify(req.query)};
try {
const cached = await client.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
// Override res.json to cache response
const originalJson = res.json.bind(res);
res.json = (data) => {
client.setEx(key, 300, JSON.stringify(data)); // 5 min TTL
originalJson(data);
};
next();
} catch (error) {
next(); // Proceed without cache on error
}
}
app.get('/api/articles', cacheMiddleware, getArticles);
CDN Edge Caching
// Set cache headers for CDN
app.get('/api/articles/:slug', async (req, res) => {
const article = await getArticle(req.params.slug);
// Cache for 1 hour, stale-while-revalidate for 24 hours
res.set('Cache-Control', 'public, s-maxage=3600, stale-while-revalidate=86400');
res.set('CDN-Cache-Control', 'max-age=3600');
res.set('Vary', 'Accept-Encoding');
// Add ETag for conditional requests
const etag = generateETag(article);
res.set('ETag', etag);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.json(article);
});
Cache Invalidation on Publish
// Webhook handler for content updates
app.post('/webhooks/content-updated', async (req, res) => {
const { model, entry } = req.body;
// Invalidate specific cache keys
const keys = [
api:/articles/${entry.slug},
api:/articles?*, // List queries
home:articles // Homepage cache
];
await Promise.all(keys.map(pattern => {
if (pattern.includes('*')) {
return client.keys(pattern).then(matchedKeys =>
Promise.all(matchedKeys.map(k => client.del(k)))
);
}
return client.del(pattern);
}));
// Trigger CDN purge
await purgeCDN([
https://site.com/articles/${entry.slug},
https://site.com/
]);
res.sendStatus(200);
});
Cache hierarchy:
- CDN edge (Cloudflare, Fastly): 1-24 hours
- Application cache (Redis): 5-15 minutes
- Database query cache: 30-60 seconds
- On publish: Invalidate all layers
Authentication & Permissions
Content APIs need robust access control.
API Key Authentication
// Simple API key middleware
function authenticateAPIKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'API key required' });
}
// Validate against database
const client = await db.apiKeys.findOne({ key: apiKey, active: true });
if (!client) {
return res.status(403).json({ error: 'Invalid API key' });
}
// Rate limiting per key
const requestCount = await redis.incr(ratelimit:${apiKey}:${Date.now()});
if (requestCount > client.rateLimit) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
req.client = client;
next();
}
JWT for User-Specific Content
// JWT middleware
const jwt = require('jsonwebtoken');
function authenticateJWT(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
} catch (error) {
return res.status(403).json({ error: 'Invalid token' });
}
}
// User-specific content endpoint
app.get('/api/my/articles', authenticateJWT, async (req, res) => {
const articles = await db.articles.find({
author: req.user.id,
status: { $in: ['draft', 'published'] }
});
res.json(articles);
});
Role-Based Access Control (RBAC)
// Strapi RBAC example
{
"routes": {
"article.find": {
"policies": ["isAuthenticated", "hasRole:editor"]
},
"article.findOne": {
"policies": [] // Public
},
"article.create": {
"policies": ["isAuthenticated", "hasRole:editor,admin"]
},
"article.update": {
"policies": ["isAuthenticated", "isOwnerOrAdmin"]
}
}
}
// Custom policy: isOwnerOrAdmin
module.exports = async (ctx, next) => {
const { id } = ctx.params;
const article = await strapi.db.query('api::article.article').findOne({ where: { id } });
const isOwner = article.author.id === ctx.state.user.id;
const isAdmin = ctx.state.user.role.type === 'admin';
if (isOwner || isAdmin) {
return next();
}
return ctx.unauthorized('You cannot update this article');
};
Preview & Draft Content
Let editors see changes before publish.
Preview Tokens
// Generate short-lived preview token
app.post('/api/preview/generate', authenticateJWT, async (req, res) => {
const { articleId } = req.body;
const token = jwt.sign(
{ articleId, type: 'preview' },
process.env.PREVIEW_SECRET,
{ expiresIn: '1h' }
);
const previewUrl = https://site.com/api/preview?token=${token};
res.json({ previewUrl });
});
// Serve draft content with valid token
app.get('/api/articles/:slug/draft', async (req, res) => {
const token = req.query.token;
try {
const { articleId } = jwt.verify(token, process.env.PREVIEW_SECRET);
const article = await db.articles.findOne({ id: articleId });
// Bypass cache, return draft
res.json(article);
} catch (error) {
res.status(403).json({ error: 'Invalid preview token' });
}
});
Next.js Preview Mode Integration
// pages/api/preview.js
export default async function handler(req, res) {
const { token, slug } = req.query;
// Verify token
const article = await verifyPreviewToken(token);
if (!article) {
return res.status(401).json({ message: 'Invalid token' });
}
// Enable Preview Mode
res.setPreviewData({ articleId: article.id });
// Redirect to the path
res.redirect(/articles/${slug});
}
// pages/articles/[slug].js
export async function getStaticProps({ params, preview = false, previewData }) {
const article = preview
? await fetchDraftArticle(previewData.articleId)
: await fetchPublishedArticle(params.slug);
return { props: { article, preview } };
}
Webhooks for Real-Time Sync
Notify external systems on content changes.
// Webhook dispatcher
class WebhookDispatcher {
constructor() {
this.endpoints = [
{ url: 'https://netlify.com/build_hooks/abc123', events: ['article.publish'] },
{ url: 'https://algolia.com/webhook', events: ['article.*', 'category.*'] },
{ url: 'https://slack.com/webhook/xyz', events: ['*.publish'] }
];
}
async dispatch(event, data) {
const targets = this.endpoints.filter(ep =>
ep.events.some(pattern => this.matchEvent(event, pattern))
);
await Promise.allSettled(
targets.map(target =>
fetch(target.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': this.signPayload(data, target.secret)
},
body: JSON.stringify({ event, data, timestamp: Date.now() })
})
)
);
}
matchEvent(event, pattern) {
const regex = new RegExp('^' + pattern.replace('', '.') + '$');
return regex.test(event);
}
signPayload(data, secret) {
return crypto
.createHmac('sha256', secret)
.update(JSON.stringify(data))
.digest('hex');
}
}
// Trigger on lifecycle hooks
strapi.db.lifecycles.subscribe({
models: ['api::article.article'],
async afterCreate(event) {
await webhookDispatcher.dispatch('article.create', event.result);
},
async afterUpdate(event) {
if (event.result.publishedAt && !event.params.data.publishedAt) {
await webhookDispatcher.dispatch('article.publish', event.result);
}
}
});
Image Optimization Pipeline
Serve responsive images automatically.
// Cloudinary integration
const cloudinary = require('cloudinary').v2;
// Upload on media creation
async function handleMediaUpload(file) {
const result = await cloudinary.uploader.upload(file.path, {
folder: 'cms-uploads',
resource_type: 'auto',
transformation: [
{ quality: 'auto', fetch_format: 'auto' }
]
});
return {
url: result.secure_url,
publicId: result.public_id,
width: result.width,
height: result.height,
format: result.format
};
}
// Generate responsive image URLs
function getResponsiveUrls(publicId) {
const sizes = [320, 640, 768, 1024, 1280, 1920];
return sizes.map(width => ({
width,
url: cloudinary.url(publicId, {
width,
crop: 'scale',
quality: 'auto',
fetch_format: 'auto'
})
}));
}
// API response includes srcset
app.get('/api/media/:id', async (req, res) => {
const media = await db.media.findById(req.params.id);
res.json({
...media,
srcset: getResponsiveUrls(media.publicId)
.map(img => ${img.url} ${img.width}w)
.join(', '),
sizes: '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px'
});
});
Real-World Implementation: Blog Platform
Complete headless CMS for a multi-tenant blog platform.
Content Models
// models/article.js
module.exports = {
attributes: {
title: { type: 'string', required: true, maxLength: 100 },
slug: { type: 'string', required: true, unique: true },
summary: { type: 'text', maxLength: 200 },
content: { type: 'dynamiczone', blocks: ['text', 'image', 'code', 'video'] },
status: { type: 'enumeration', enum: ['draft', 'published', 'archived'], default: 'draft' },
publishedAt: { type: 'datetime' },
featuredImage: { type: 'media', allowedTypes: ['images'] },
author: { type: 'relation', relation: 'manyToOne', target: 'plugin::users-permissions.user' },
categories: { type: 'relation', relation: 'manyToMany', target: 'api::category.category' },
tags: { type: 'relation', relation: 'manyToMany', target: 'api::tag.tag' },
seo: {
type: 'component',
component: 'shared.seo',
required: true
},
tenant: { type: 'string', required: true }
}
};
API Endpoints
// routes/article.js
module.exports = {
routes: [
{
method: 'GET',
path: '/articles',
handler: 'article.find',
config: { policies: [] } // Public
},
{
method: 'GET',
path: '/articles/:slug',
handler: 'article.findOne',
config: { policies: [] }
},
{
method: 'POST',
path: '/articles',
handler: 'article.create',
config: { policies: ['isAuthenticated', 'hasRole:editor'] }
},
{
method: 'PUT',
path: '/articles/:id',
handler: 'article.update',
config: { policies: ['isAuthenticated', 'isOwnerOrAdmin'] }
},
{
method: 'POST',
path: '/articles/:id/publish',
handler: 'article.publish',
config: { policies: ['isAuthenticated', 'hasRole:editor'] }
}
]
};
// controllers/article.js
module.exports = {
async publish(ctx) {
const { id } = ctx.params;
const article = await strapi.entityService.update('api::article.article', id, {
data: {
status: 'published',
publishedAt: new Date()
}
});
// Trigger webhooks
await strapi.webhooks.trigger('article.publish', article);
// Invalidate cache
await strapi.cache.invalidate(`article:${article.slug}`);
ctx.send(article);
}
};
Frontend Integration (Next.js)
// lib/cms.js
const CMS_API_URL = process.env.CMS_API_URL;
const CMS_API_KEY = process.env.CMS_API_KEY;
export async function getArticles( = {}) {
const res = await fetch(
${CMS_API_URL}/articles?pagination[page]=${page}&pagination[pageSize]=${pageSize}&sort=publishedAt:desc,
{
headers: { 'Authorization': Bearer ${CMS_API_KEY} },
next: { revalidate: 60 } // ISR: revalidate every 60s
}
);
return res.json();
}
export async function getArticle(slug) {
const res = await fetch(
${CMS_API_URL}/articles/${slug}?populate=author,categories,featuredImage,
{
headers: { 'Authorization': Bearer ${CMS_API_KEY} },
next: { revalidate: 300 }
}
);
return res.json();
}
// app/articles/[slug]/page.js
export async function generateStaticParams() {
const { data: articles } = await getArticles({ pageSize: 100 });
return articles.map(article => ({ slug: article.attributes.slug }));
}
export default async function ArticlePage({ params }) {
const article = await getArticle(params.slug);
return (
<article>
<h1>{article.data.attributes.title}</h1>
<RenderBlocks blocks={article.data.attributes.content} />
</article>
);
}
Performance Benchmarks
Real-world API performance with caching:
| Scenario | No Cache | Redis Cache | CDN Edge |
|---|---|---|---|
| Single article | 85ms | 8ms | 12ms |
| Article list (10) | 120ms | 15ms | 18ms |
| Complex query (joins) | 250ms | 22ms | 25ms |
| Media delivery | N/A | N/A | 8ms |
Optimization impact:
- Redis cache: 90% latency reduction
- CDN edge: 95% origin requests eliminated
- Image CDN: 70% bandwidth saved
Platform Comparison
| Feature | Strapi | Contentful | Sanity | Custom |
|---|---|---|---|---|
| Self-hosted | ✅ | ❌ | ❌ | ✅ |
| GraphQL | ✅ | ✅ | ✅ | Build it |
| Visual Editor | ✅ | ✅ | ✅ | Build it |
| Webhooks | ✅ | ✅ | ✅ | Build it |
| Free tier | Unlimited | 25k records | 100k requests | N/A |
| Learning curve | Medium | Low | Medium | High |
| Customization | High | Low | Medium | Unlimited |
| Cost (10M requests) | $0 (self-host) | $4,800/mo | $2,400/mo | $200/mo |
Conclusion
Headless CMS architecture unlocks omnichannel content delivery with unprecedented flexibility. The patterns covered here—API design, caching hierarchies, access control, webhook orchestration—form the foundation of modern content infrastructure.
Key principles:
- API-first: Content is data, not pages
- Cache aggressively: Read-heavy workloads demand multi-layer caching
- Secure by default: Authentication, authorization, rate limiting
- Optimize images: Responsive delivery, automatic format selection
- Decouple preview: Editors need real-time feedback without invalidating caches
Whether you choose a hosted platform (Contentful, Sanity) or self-hosted (Strapi, Directus), or build custom, these patterns apply universally.
Next steps:
- Audit your current CMS: Can it serve mobile apps? IoT devices?
- Prototype with Strapi (free, self-hosted) or Contentful (generous free tier)
- Implement incremental static regeneration (ISR) for near-instant pages
- Monitor API performance: Set SLOs for p50, p95, p99 latencies
Resources:
Related Articles
GraphQL API Design - Production Architecture and Best Practices for Scalable Systems
Master GraphQL API design covering schema design principles, resolver optimization, N+1 query prevention with DataLoader, authentication and authorization patterns, caching strategies, error handling, and production deployment for high-performance GraphQL systems.
Testing Strategies - Unit, Integration, and E2E Testing Best Practices for Production Quality
Comprehensive guide to testing strategies covering unit tests, integration tests, end-to-end testing, test-driven development, mocking patterns, testing pyramid, and production testing practices for reliable software delivery.
Monitoring and Observability - Production Systems Performance and Debugging at Scale
Master monitoring and observability covering metrics collection with Prometheus, distributed tracing with OpenTelemetry, log aggregation, alerting strategies, SLOs/SLIs, and production debugging techniques for reliable systems.
Written by StaticBlock Editorial
StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.