Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 087 / 100

Headless CMS with Next.js for React & Next.js developers

Lessons from building on Contentful.

Expona Admin9 min read

Headless CMS with Next.js transforms how teams manage content at scale, decoupling editorial workflows from frontend deployment cycles. Over the last five years building production platforms, I've watched this pattern go from optional to essential—especially when non-technical editors need to ship content without touching code. Let me share what actually works, what doesn't, and where most teams stumble.

Why Headless CMS Matters in Modern Frontend

Traditional WordPress or Drupal setups couple your content storage, rendering, and admin interface together. You're locked into their theming system, their performance characteristics, their scaling limits. A headless CMS strips that down to an API—Contentful, Sanity, Strapi, whatever—and lets your Next.js app consume content as JSON.

The real win isn't just decoupling. It's speed. Your editors can publish without waiting for a deploy pipeline. Your frontend can optimize independently. Your content becomes truly portable—use it across web, mobile, even third-party integrations. But only if you build it right.

Model Content, Not Pages

This is where most teams make their first mistake. They create a "Blog Post" content type with a title field, a body field, and call it done. Six months later, they need related posts, taxonomies, author links, featured images with alt text, and suddenly the schema is brittle.

Start by thinking about your entities. In an e-commerce platform, you have Products, Categories, Variants, Reviews. In a publishing site, you have Articles, Authors, Topics, Media. Each entity should be granular and reusable.

Here's a realistic Contentful schema I used on a travel platform:

tsx
// types/contentful.ts
export interface ContentfulAsset {
  sys: {
    id: string;
  };
  fields: {
    title: string;
    file: {
      url: string;
      contentType: string;
    };
  };
}

export interface ContentfulAuthor {
  sys: {
    id: string;
  };
  fields: {
    name: string;
    email: string;
    bio: string;
    photo: ContentfulAsset;
  };
}

export interface ContentfulArticle {
  sys: {
    id: string;
    createdAt: string;
    updatedAt: string;
  };
  fields: {
    title: string;
    slug: string;
    excerpt: string;
    body: string;
    featuredImage: ContentfulAsset;
    author: ContentfulAuthor;
    tags: string[];
    status: 'draft' | 'published';
    publishedAt: string;
  };
}

Notice the structure: separate assets, separate authors, timestamps for state management. This is modular. When you need to add author social links or image crops later, you extend Author or Asset—not Article. Your queries become consistent across your app.

In Next.js, always fetch these independently when you can. Avoid deeply nested fetches. Your CMS API will thank you, and your caching strategy will be cleaner.

Preview Mode for Editors

Editors hate surprise deployments. They want to see how their draft looks live before publishing. Next.js preview mode solves this elegantly.

Preview mode temporarily sets a cookie and renders draft content. It's not production—performance doesn't matter. What matters is that your editor sees exactly what will appear when they hit publish.

tsx
// pages/api/preview.ts
import type { NextApiRequest, NextApiResponse } from 'next';

const PREVIEW_SECRET = process.env.PREVIEW_SECRET;

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  // Validate the secret—never expose this publicly
  if (req.query.secret !== PREVIEW_SECRET) {
    return res.status(401).json({ message: 'Invalid secret' });
  }

  // Enable preview mode
  res.setPreviewData({});

  // Redirect to the draft content
  const slug = req.query.slug as string;
  res.redirect(`/articles/${slug}`);
}

// pages/articles/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchArticle, fetchAllArticleSlugs } from '@/lib/contentful';

export default function ArticlePage({ article, isPreview }: {
  article: ContentfulArticle;
  isPreview: boolean;
}) {
  return (
    <article>
      {isPreview && (
        <div>
          Preview mode active. Changes won't appear to readers until published.
        </div>
      )}
      <h1>{article.fields.title}</h1>
      <p>{article.fields.body}</p>
    </article>
  );
}

export const getStaticProps: GetStaticProps = async (context) => {
  const { slug } = context.params as { slug: string };
  const isPreview = context.preview || false;

  // In preview mode, always fetch fresh draft content
  const article = await fetchArticle(slug, isPreview);

  if (!article) {
    return { notFound: true };
  }

  return {
    props: {
      article,
      isPreview,
    },
    // Don't cache preview requests
    revalidate: isPreview ? 0 : 3600,
  };
};

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await fetchAllArticleSlugs();
  return {
    paths: slugs.map(slug => ({ params: { slug } })),
    fallback: 'blocking',
  };
};

Your Contentful webhook calls this endpoint with the preview secret. The editor gets a shareable URL. When they visit, they see draft content in real-time. When they publish via Contentful, the webhook triggers revalidation (covered next), and readers see the live version within seconds.

Revalidate on Publish Webhooks

Incremental Static Regeneration (ISR) is beautiful, but there's a trap: if you set revalidate: 3600, readers might wait an hour to see new content. Webhooks solve this.

Contentful fires a webhook the instant an entry is published. Your Next.js app catches it and triggers revalidate on just that path—seconds, not hours.

tsx
// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';

const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET;

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // Validate the secret
  if (req.query.secret !== REVALIDATE_SECRET) {
    return res.status(401).json({ message: 'Invalid secret' });
  }

  const { slug, contentType } = req.body;

  try {
    // Revalidate the specific article
    if (contentType === 'article') {
      await res.revalidate(`/articles/${slug}`);
    }

    // Also revalidate listing pages
    await res.revalidate('/articles');
    await res.revalidate('/');

    return res.json({
      revalidated: true,
      timestamp: new Date().toISOString(),
    });
  } catch (err) {
    return res.status(500).json({
      message: 'Error revalidating',
      error: (err as Error).message,
    });
  }
}

Contentful webhook configuration would point to https://yourdomain.com/api/revalidate?secret=YOUR_SECRET with the slug and content type in the request body. When an editor publishes, Next.js regenerates that page in the background. The old cached version serves immediately while the new one builds—users never wait.

Cache CMS Responses Aggressively

Your CMS API has rate limits and latency. Contentful gives you 7 requests per second on the free tier. Cache aggressively or you'll hit limits at scale.

I use a three-layer approach:

  1. Request-level deduplication: Within a single page render, don't fetch the same asset twice.
  2. Process-level caching: Redis or in-memory cache for seconds to minutes.
  3. Static generation: Build-time fetches that never hit the API again (until revalidation).
tsx
// lib/contentful.ts
import * as contentful from 'contentful';

const client = contentful.createClient({
  space: process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_ACCESS_TOKEN!,
  host: 'cdn.contentful.com', // Use CDN, not preview
});

// Simple in-memory cache for development/small apps
const memoryCache = new Map();

const CACHE_TTL_SECONDS = 300; // 5 minutes

async function fetchWithCache(
  key: string,
  fetcher: () => Promise
): Promise {
  // Check cache
  const cached = memoryCache.get(key);
  const now = Date.now();

  if (cached && now - cached.time < CACHE_TTL_SECONDS * 1000) {
    return cached.data as T;
  }

  // Fetch fresh
  const data = await fetcher();
  memoryCache.set(key, { data, time: now });
  return data;
}

export async function fetchArticle(
  slug: string,
  isDraft: boolean = false
): Promise {
  const cacheKey = `article:${slug}:${isDraft ? 'draft' : 'published'}`;

  return fetchWithCache(cacheKey, async () => {
    const response = await client.getEntries({
      content_type: 'article',
      'fields.slug': slug,
      include: 2, // Include author and featured image
    });

    return response.items[0] || null;
  });
}

export async function fetchAllArticles(limit = 50): Promise {
  const cacheKey = 'articles:all';

  return fetchWithCache(cacheKey, async () => {
    const response = await client.getEntries({
      content_type: 'article',
      limit,
      order: '-fields.publishedAt',
    });

    return response.items;
  });
}

For production with higher traffic, swap the memory cache for Redis:

tsx
// lib/redis.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

async function fetchWithRedisCache(
  key: string,
  fetcher: () => Promise,
  ttlSeconds: number = 300
): Promise {
  // Try Redis
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }

  // Fetch fresh
  const data = await fetcher();
  await redis.setex(key, ttlSeconds, JSON.stringify(data));
  return data;
}

Always use the CDN endpoint (cdn.contentful.com), not the preview API, for published content. The CDN is globally distributed and cached. Your queries will be fast.

Common Mistakes

  • Over-nesting content types. Don't make Article contain Author contain Organization. Keep them flat and link by ID. Easier to reuse, easier to cache independently.
  • Forgetting revalidation paths. You revalidate the article detail page but forget the listing. Readers see stale lists for hours.
  • No cache invalidation strategy. You cache aggressively, then a typo gets published and stays cached for 5 minutes. Consider stale-while-revalidate headers or manual cache clearing.
  • Exposing preview/draft content publicly. Always check user permissions or use Next.js preview mode. Never serve drafts in production builds.
  • Storing content as strings. Rich text from Contentful usually comes as JSON (Document format). Parse and validate it, don't render raw strings. Prevent XSS.

Key Takeaways

  • Model granular, reusable entities (Articles, Authors, Assets) instead of monolithic page types. Easier to extend, easier to cache.
  • Use Next.js preview mode with a secret endpoint. Editors see drafts before publishing, without touching production.
  • Trigger ISR revalidation via Contentful webhooks on publish. Content goes live in seconds, not hours.
  • Cache CMS responses in-memory or Redis with a reasonable TTL. Respect rate limits and reduce latency. Use the CDN endpoint for published content.
  • Test your webhook flow end-to-end before launch. A broken revalidation endpoint means stale content silently.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles