Skip to content

Headless Content Delivery with Graph

intermediate
📜CoreGraphcms

Traditional CMS architecture couples content management to content rendering. The CMS stores the content and also generates the HTML. This works well until you need to serve the same content to a mobile app, a kiosk, a partner site, or a single-page application — each requiring different rendering logic.

Optimizely Graph decouples these layers. The CMS remains the system of record for content. Graph indexes that content and exposes it through a GraphQL API. Your frontend — a Next.js app, a React SPA, a mobile client — queries Graph for exactly the data it needs and renders it using its own component library. The CMS and the frontend evolve independently.

This recipe walks through the complete architecture: how content flows from the CMS editor to Graph to a Next.js frontend, with query patterns you can adapt to your content model.

┌─────────────────────────────────────────────────────────┐
│ Content Authors │
│ (CMS Editor / Visual Builder) │
└────────────────────────┬────────────────────────────────┘
│ Create, edit, publish
┌─────────────────────────────────────────────────────────┐
│ Optimizely CMS │
│ (SaaS or PaaS instance) │
│ │
│ Content types → Content instances → Publishing events │
└────────────────────────┬────────────────────────────────┘
│ Automatic sync on publish
┌─────────────────────────────────────────────────────────┐
│ Optimizely Graph │
│ │
│ • Indexes all published content │
│ • Generates GraphQL schema from content types │
│ • Supports filtering, sorting, full-text search │
│ • Handles localization and personalization queries │
│ • Provides real-time and cached endpoints │
└────────────────────────┬────────────────────────────────┘
│ GraphQL queries
┌─────────────────────────────────────────────────────────┐
│ Frontend Application │
│ (Next.js / React / Vue) │
│ │
│ • Queries Graph at build time (SSG) or request time │
│ (SSR/ISR) │
│ • Maps content types to React components │
│ • Handles routing, layout, and interactivity │
│ • Deploys independently (Vercel, Netlify, AWS, etc.) │
└─────────────────────────────────────────────────────────┘
  1. Author publishes content in the CMS editor (a page, a component, a media item)
  2. CMS sends a publish event to Graph. On CMS SaaS, this is automatic. On CMS PaaS, Graph syncs via the configured integration.
  3. Graph indexes the content and updates its GraphQL schema if new content types or properties were added
  4. Frontend queries Graph using GraphQL — either at build time (static generation), at request time (server-side rendering), or with incremental static regeneration (ISR) for a balance of performance and freshness
  5. Frontend renders the response using React components that map to CMS content types

Retrieve your Graph credentials from the Optimizely dashboard:

  • Graph endpoint — The GraphQL API URL for your environment
  • Single key — For public, cached queries (suitable for frontend use)
  • HMAC key — For authenticated queries with draft content access (use server-side only)
Next.js project setup with Graph client
bash
# Create a new Next.js application
npx create-next-app@latest my-opti-site --typescript --app
cd my-opti-site

# Install a GraphQL client
npm install graphql-request graphql
typescript
import { GraphQLClient } from 'graphql-request';

const GRAPH_ENDPOINT = process.env.OPTIMIZELY_GRAPH_ENDPOINT!;
const GRAPH_KEY = process.env.OPTIMIZELY_GRAPH_SINGLE_KEY!;

export const graphClient = new GraphQLClient(GRAPH_ENDPOINT, {
  headers: {
    Authorization: `Bearer ${GRAPH_KEY}`,
  },
});

Store your Graph credentials in .env.local:

Environment variables
bash
OPTIMIZELY_GRAPH_ENDPOINT=https://cg.optimizely.com/content/v2
OPTIMIZELY_GRAPH_SINGLE_KEY=your-single-key-here

Graph generates a GraphQL schema from your CMS content types. Each content type becomes a queryable type in the schema.

Query a landing page with components
typescript
import { gql } from 'graphql-request';

export const GET_LANDING_PAGE = gql`
  query GetLandingPage($url: String!) {
    LandingPage(
      where: {
        _metadata: { url: { default: { eq: $url } } }
        _metadata: { status: { eq: "Published" } }
      }
    ) {
      items {
        _metadata {
          key
          displayName
          url {
            default
          }
        }
        metaTitle
        metaDescription
        mainContent {
          __typename
          ... on HeroBlock {
            heading
            bodyText
            backgroundImage {
              url
            }
            ctaText
            ctaLink
          }
          ... on TextBlock {
            heading
            body
          }
          ... on CardBlock {
            title
            description
            image {
              url
            }
            linkUrl
          }
        }
      }
    }
  }
`;

export const GET_ALL_PAGES = gql`
  query GetAllPages {
    LandingPage(where: { _metadata: { status: { eq: "Published" } } }) {
      items {
        _metadata {
          url {
            default
          }
        }
      }
    }
  }
`;

Step 4: Map content types to React components

Section titled “Step 4: Map content types to React components”

Create a component registry that maps CMS content type names to React components.

Component mapping
tsx
import { HeroBanner } from './HeroBanner';
import { TextSection } from './TextSection';
import { ContentCard } from './ContentCard';

// Map CMS content type names to React components
const componentMap: Record<string, React.ComponentType<any>> = {
  HeroBlock: HeroBanner,
  TextBlock: TextSection,
  CardBlock: ContentCard,
};

interface ContentRendererProps {
  items: Array<{ __typename: string; [key: string]: any }>;
}

export function ContentRenderer({ items }: ContentRendererProps) {
  return (
    <>
      {items.map((item, index) => {
        const Component = componentMap[item.__typename];
        if (!Component) {
          console.warn(`No component for type: ${item.__typename}`);
          return null;
        }
        return <Component key={index} {...item} />;
      })}
    </>
  );
}
Dynamic page route with ISR
tsx
import { graphClient } from '@/lib/graph-client';
import { GET_LANDING_PAGE, GET_ALL_PAGES } from '@/lib/queries';
import { ContentRenderer } from '@/components/content-renderer';
import { notFound } from 'next/navigation';

interface PageProps {
  params: { slug: string[] };
}

export async function generateStaticParams() {
  const data = await graphClient.request(GET_ALL_PAGES);
  return data.LandingPage.items.map((page: any) => ({
    slug: page._metadata.url.default.split('/').filter(Boolean),
  }));
}

export default async function Page({ params }: PageProps) {
  const url = '/' + params.slug.join('/');
  const data = await graphClient.request(GET_LANDING_PAGE, { url });
  const page = data.LandingPage?.items?.[0];

  if (!page) {
    notFound();
  }

  return (
    <main>
      <h1>{page._metadata.displayName}</h1>
      <ContentRenderer items={page.mainContent || []} />
    </main>
  );
}

// Revalidate every 60 seconds (ISR)
export const revalidate = 60;
StrategyWhen to useGraph query timing
Static Generation (SSG)Marketing pages, blog posts — content changes infrequentlyBuild time only
Incremental Static Regeneration (ISR)Content updated regularly but does not need instant reflectionBuild time + background revalidation
Server-Side Rendering (SSR)Personalized content, preview mode, content that must be instantEvery request

For most content-driven sites, ISR provides the best balance. Pages load fast from cache, and Graph serves fresh content within the revalidation window.

To let CMS authors preview unpublished content in the Next.js frontend, use the HMAC key and query for draft content.

Draft content preview
typescript
import { GraphQLClient } from 'graphql-request';

// HMAC key allows access to draft content
// Use server-side only — never expose in client bundles
const GRAPH_HMAC_KEY = process.env.OPTIMIZELY_GRAPH_HMAC_KEY!;

export const previewClient = new GraphQLClient(
  process.env.OPTIMIZELY_GRAPH_ENDPOINT!,
  {
    headers: {
      Authorization: `Bearer ${GRAPH_HMAC_KEY}`,
    },
  }
);

This architecture fits when:

  • You need to serve content to multiple channels (web, mobile, kiosks) from a single CMS
  • Your frontend team works in React, Next.js, Vue, or another JavaScript framework rather than .NET
  • You want to deploy the frontend independently of the CMS
  • Performance is critical and you want edge caching or static generation
  • You are on CMS SaaS (headless-first by design) or migrating CMS PaaS toward headless delivery