0% read
Skip to main content
Headless CMS Architecture: Building Modern Content Infrastructure

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.

S
StaticBlock Editorial
15 min read

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:

  1. CDN edge (Cloudflare, Fastly): 1-24 hours
  2. Application cache (Redis): 5-15 minutes
  3. Database query cache: 30-60 seconds
  4. 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:

Found this helpful? Share it!

Related Articles

S

Written by StaticBlock Editorial

StaticBlock Editorial is a technical writer and software engineer specializing in web development, performance optimization, and developer tooling.