Headless & Composable CMS · 7 min read

Contentstack and Next.js: Building a Composable Front End

Delivery API access, live preview, webhook-driven revalidation and rendering modular blocks as typed React components.

By Praful Patel · Last updated
Contentstack and Next.js: Building a Composable Front End - article cover
Contentstack and Next.js: Building a Composable Front End - article cover

Short answer

Three things decide whether a Contentstack and Next.js build is pleasant or painful: typed content models so the front end knows what it is receiving, live preview that editors actually trust, and webhook-driven revalidation so publishing updates the site without a deploy. Fetching content is the easy part.

The integration itself is a Delivery API call. What follows is the surrounding machinery that turns it into something an editorial team can use.

Delivery API, not Management API

Contentstack exposes two APIs and confusing them is the first mistake. The Delivery API is read-only, CDN-backed and designed for front-end traffic. The Management API writes content, is rate-limited far more tightly, and its token must never reach a browser.

// lib/contentstack.ts - server-only module.
import contentstack from '@contentstack/delivery-sdk';

export const stack = contentstack.stack({
  apiKey:        process.env.CONTENTSTACK_API_KEY!,
  deliveryToken: process.env.CONTENTSTACK_DELIVERY_TOKEN!,
  environment:   process.env.CONTENTSTACK_ENVIRONMENT!,   // 'production' | 'preview'
});

export async function getArticle(slug: string) {
  const result = await stack
    .contentType('article')
    .entry()
    .query()
    .where('url', slug)
    .includeReference(['author', 'category'])   // keep this list short
    .find<Article>();

  return result.entries?.[0] ?? null;
}

includeReference is where cost accumulates. Each reference resolved is work on every uncached request; ask only for what the page renders.

Type the content model

Generate TypeScript types from the content types rather than hand-writing interfaces that drift. Contentstack’s CLI can emit them, and regenerating on model change turns a runtime undefined into a compile error.

This matters most for modular blocks, where the page is a heterogeneous array. A discriminated union plus an exhaustive switch means adding a block type to the model produces a type error until the front end handles it:

function Block({ block }: { block: PageBlock }) {
  switch (block.__typename) {
    case 'hero':      return <Hero {...block} />;
    case 'quote':     return <Quote {...block} />;
    case 'card_grid': return <CardGrid {...block} />;
    default: {
      // Compile error if a new block type is added to the model.
      const _exhaustive: never = block;
      // Never crash the page in production over one unknown block.
      return process.env.NODE_ENV === 'development' ? <UnknownBlock /> : null;
    }
  }
}

Live preview

Preview is the feature that decides whether editors accept the platform. Contentstack’s Live Preview shows unpublished changes in your real front end, which means the front end must be able to run against draft content.

The moving parts: a preview token and the preview host rather than the delivery host, a route that only serves draft content to an authenticated request, and — critically — caching disabled on that path. A preview that serves cached content is worse than no preview, because it looks like the CMS lost the edit.

// Draft reads must never be cached, and must never be reachable publicly.
const entry = await previewStack.contentType('article').entry(uid)
  .fetch({ cache: 'no-store' });

Webhook-driven revalidation

Static rendering with on-publish invalidation gives you static performance and editorial immediacy. A webhook from Contentstack hits a route handler which revalidates the affected tags.

export async function POST(request: Request) {
  // The endpoint is public: verify before doing any work.
  if (request.headers.get('x-cs-signature') !== process.env.CS_WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const { data } = await request.json();
  const type = data?.content_type?.uid;

  revalidateTag(`contentstack:${type}`);
  if (data?.entry?.url) revalidatePath(data.entry.url);

  return Response.json({ revalidated: true });
}

Tag every fetch with its content type so one webhook invalidates everything derived from it. Keep a modest time-based revalidate as a backstop — webhooks are delivered at least once and occasionally not at all, so a missed one should mean content is an hour stale, not permanently wrong. The layered behaviour behind this is in Next.js Caching Explained.

Images and assets

Contentstack’s Image Delivery API resizes and re-encodes via URL parameters, which means you can skip Next.js image optimisation entirely and avoid doing that work on your own infrastructure. Write a custom loader that maps Next’s sizing to Contentstack’s parameters, and you keep next/image‘s layout behaviour while the CDN does the processing — relevant to the trade-offs in Deploying Next.js.

Environments

Contentstack environments map cleanly onto deployment environments: a development environment for the preview build, production for the live site. Each has its own delivery token. Keep them genuinely separate — pointing a staging front end at production content removes the only safe place to test a model change.

Rendering strategy per route

Not every route in a content site wants the same treatment, and applying one strategy everywhere is how a build ends up either stale or slow.

Route Strategy Why
Article and landing pages Static, revalidated by webhook Same for everyone; changes are editorial events
Listing and category pages Static with a tag Invalidated when any member entry publishes
Search results Dynamic Depends entirely on the query string
Preview routes Dynamic, no-store Must never serve a cached draft
Personalised sections Client-fetched after paint Keeps the surrounding page cacheable

The pattern worth adopting: use generateStaticParams for the entries that exist at build time and let the rest render on first request and then cache. That gives fast builds on a large catalogue without giving up static delivery.

export async function generateStaticParams() {
  // Pre-build the recent set; older entries render on demand and cache after.
  const recent = await stack.contentType('article').entry().query()
    .limit(200).orderByDescending('published_at').find<Article>();

  return (recent.entries ?? []).map(e => ({ slug: e.url.replace(/^\//, '') }));
}

// Anything not pre-built is generated on first request rather than 404ing.
export const dynamicParams = true;

Handling missing and unpublished entries

An entry unpublished from an environment disappears from the Delivery API, and a route that assumed it exists will throw. Call notFound() explicitly so the framework renders your 404 rather than surfacing a runtime error, and make sure a removed entry’s URL actually returns a 404 status rather than a soft error page — search engines treat those very differently.

Questions people actually ask

Delivery API or GraphQL?
Both are available. The REST Delivery API is CDN-cached and simpler to reason about; GraphQL avoids over-fetching and resolves references in one query. For a single Next.js front end doing server-side fetching, REST is usually the pragmatic choice because caching works without extra machinery.

How do I stop preview content leaking to public visitors?
Use a separate preview token and environment, gate the preview route behind Next.js draft mode with a secret, and never use the preview stack in the normal render path. Verify it by loading a page as an anonymous visitor while an entry has unpublished changes.

Why is the site slow despite the CDN?
Usually deep includeReference chains resolving on every request, or fetching in a route that has opted out of caching. Check whether the route is actually static in the build output, and reduce reference depth to what the page renders.

Do I need Next.js image optimisation with Contentstack?
Generally no. The Image Delivery API already resizes and re-encodes at the CDN edge. Use a custom loader so next/image keeps handling layout and lazy loading while Contentstack does the transformation – that avoids paying for the same work twice.

How should I handle a content model change?
Add fields as optional first and deploy a front end that tolerates their absence. Migrate entries, then make the field required once every entry has a value. Reversing that order breaks unpublished entries and blocks editors mid-workflow.

← Back to all insights