Developer Demo

Graph Query Design

How this project talks to Optimizely Graph - the patterns that keep queries fast, cacheable, and free of N+1 problems.

One request, all blocks - the SDK page query#

getContentByPath() issues a single GraphQL request to Optimizely Graph. The CMS SDK looks at every component registered in componentRegistry.ts and generates an inline ... on BlockType { <fields> } spread for each one. This project registers ~32 block and page types - all their data arrives in one response with no per-block round-trips. SDK docs ↗

SDK-generated (one request)

Theoretical GraphQL - generated by the SDK
# What getContentByPath() sends to Optimizely Graph
# The CMS SDK builds this automatically from your registered components.
# ~32 block types → one request → all page data arrives in one response.

query GetPage($url: String!, $variation: VariationInput) {
  _Content(
    where: { _metadata: { url: { default: { eq: $url } } } }
    variation: $variation
    limit: 10
  ) {
    items {
      __typename
      _metadata { key version url { default } variation }
      ... on DynamicExperience {
        composition {
          ... on CompositionStructureNode {
            nodes {
              ... on CompositionElementNode {
                element {
                  ... on HeroBlock {
                    headline subheadline ctaText ctaLink
                    backgroundImage { _metadata { url { default } } }
                  }
                  ... on RichTextBlock  { body { json } }
                  ... on ProductCardBlock { title description linkUrl { default } }
                  ... on TestimonialBlock { quote authorName authorRole }
                  # ... + 28 more registered block types
                }
              }
            }
          }
        }
      }
    }
  }
}

Naive alternative (N+1)

Anti-pattern - one fetch per block
// ❌ The naive approach: one fetch per block
// Even a simple page with 8 blocks fires 9 sequential requests.

async function CmsPage({ url }) {
  const page     = await graphqlFetch(PAGE_QUERY, { url });
  const hero     = await graphqlFetch(HERO_QUERY, { key: page.heroKey });
  const richText = await graphqlFetch(TEXT_QUERY, { key: page.textKey });
  const products = await graphqlFetch(CARD_QUERY, { key: page.cardKey });
  // ...

  return <Page hero={hero} text={richText} products={products} />;
}

// ✅ What getContentByPath() does instead:
// All block types registered → SDK generates one query with union spreads →
// single Graph request → all data in one response. No per-block round-trips.

When to write custom queries

The SDK page query covers registered page content. Three situations require custom graphqlFetch calls:

Non-page data - Navigation trees, site banners, quote lists - data that exists independently of any page. These live in src/lib/graphql/queries/ and are called from layout components.
Self-fetching components - Blocks placed as single content references (not content area items) are not inline-expanded by the SDK. They detect missing data and self-fetch with a guard clause.
Batch reference resolution - Blocks that receive only reference keys from the page query (TeamGridBlock, TimelineBlock) batch all keys into a single { in: $keys } query to fetch full data.

Always use the graphqlFetch wrapper from src/lib/optimizely/client.ts - it handles published vs. preview auth and ISR config automatically. Export query strings as named constants, not anonymous inline literals - stable strings benefit from Graph CDN caching (see below).

Static elements vs. dynamic content - don't pay twice#

The root layout renders GlobalBanner and NavigationHeader. These self-fetch with ISR. The CMS page route exports force-dynamic - but that only forces the page component to re-execute on every request. It does not affect the layout components' fetches, which continue to use ISR and be served from Next.js data cache and Graph CDN. A visitor on any page pays nothing extra for nav and banner data after the first request in the cache window.

Layout separation - static ISR vs force-dynamic page
// src/app/layout.tsx - these components self-fetch with ISR.
// They are NOT inside the force-dynamic page route, so their fetches
// are cached independently by Next.js and by Graph CDN.

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <GlobalBanner />      {/* graphqlFetch, revalidate: 3600 → cached */}
        <NavigationHeader />  {/* graphqlFetch, revalidate: 3600 → cached */}
        <main>{children}</main>
        <Footer />            {/* static - no fetch */}
      </body>
    </html>
  );
}

// src/app/[[...slug]]/page.tsx
export const dynamic = "force-dynamic"; // page re-executes on every request

async function CmsPage() {
  // FX decisions are per-visitor → must be fresh
  const user = await getOptimizelyUser();
  const decisions = user.decideAll();

  // Graph CDN bypass → always-fresh personalised content
  const [page] = await client.getContentByPath(url, { ...variation, cache: false });
  return <OptimizelyComponent content={page} />;
}

// layout components are NOT affected by force-dynamic on the page -
// they run in their own render scope with their own cached fetches.

GlobalBanner

Cache: revalidate: 3600

Tag: banner

Same query for all visitors

NavigationHeader

Cache: revalidate: 3600

Tag: navigation

Same query for all visitors

CMS page route

Cache: force-dynamic + cache: false

Per-visitor, always fresh

@recursive - hierarchical data in one round-trip#

@recursive(depth: N) is an Optimizely Graph extension (not standard GraphQL). It tells Graph to apply the decorated fragment to the items in a content area field at every nesting level up to the given depth - fetching arbitrary tree depth in a single request. Without it, you manually nest inline fragments for each level and the depth limit is hardcoded in the query string.

With @recursive - one query, any depth

src/lib/graphql/queries/GetNavigation.ts
# src/lib/graphql/queries/GetNavigation.ts
#
# @recursive tells Graph to apply this fragment to the 'children' content
# area field at every nesting level, up to the given depth.
# depth: 5 → Root → L1 → L2 → L3 → L4 → L5 in one single round-trip.

fragment NavItemFields on _IContent {
  ... on NavigationItem {
    __typename
    _metadata { key }
    label
    href { url { default } }
    description
    openInNewTab
    children @recursive(depth: 5)   # ← Optimizely Graph extension
  }
}

query GetNavigation {
  Navigation(limit: 1) {
    items {
      name
      navItems { ...NavItemFields }
    }
  }
}

Without @recursive - manually nested, fixed depth

Alternative without the directive
# Without @recursive - depth is hardcoded and the query grows fast.
# This handles 3 levels; adding a 4th means editing the query string.

fragment NavItemFields on _IContent {
  ... on NavigationItem {
    label
    href { url { default } }
    children {
      ... on NavigationItem {
        label
        href { url { default } }
        children {
          ... on NavigationItem {
            label
            href { url { default } }
            # Want level 4? Add another nesting block here.
          }
        }
      }
    }
  }
}

The depth parameter is also a safety cap - it prevents Graph from traversing an unbounded tree if content editors create deeply nested structures. This query supports up to 5 levels (Root → L1 → L2 → L3 → L4 → L5).

Content areas vs. single references#

How a property is declared in the content type definition determines whether Graph returns its data inline or just returns metadata.

type: "array" - content area

Graph inline-expands all items. Typed field data arrives with the page response - no extra fetch needed. Use for lists of blocks on a page.

# type: "array" content areaGraph inline-expands all items.
# FaqContainerBlock.faqItems is type: "array", so its children arrive in
# the page response with full typed fields - no extra fetch needed.

... on FaqContainerBlock {
  heading
  subheading
  faqItems {
    __typename
    ... on FaqItemBlock {
      question   # ← full field data, inline-expanded by Graph
      answer
    }
  }
}

type: "content" - single reference

Graph returns only base metadata (key, url, version) - never the referenced item's fields. The component must self-fetch if it needs real data.

# type: "content" single referenceGraph returns metadata only.
# TraditionalPage.featuredBlock is type: "content" (a single reference).
# Graph never inline-expands it - you only get the key and URL back.

... on TraditionalPage {
  headline
  featuredBlock {
    __typename
    _metadata { key url { default } version }
    # No typed fields here - Graph doesn't resolve the reference inline.
    # The component must self-fetch using the key.
  }
}

This is why FaqContainerBlock self-fetches when placed as a single reference on TraditionalPage. The guard clause if (!data.heading) detects that the page query only returned metadata and triggers a direct fetch.

src/components/blocks/FaqContainerBlock/index.tsx
// src/components/blocks/FaqContainerBlock/index.tsx
// FaqContainerBlock placed as a single reference on TraditionalPage
// receives only { __typename, _metadata } from the page query.
// The guard clause detects this and fetches the real data from Graph.

export default async function FaqContainerBlock(props) {
  let data = props.content ?? props;

  if (!data.heading) {
    // Self-fetch: data arrived as a generic reference, not inline-expanded.
    const res = await graphqlFetch(FETCH_QUERY, {}, { next: { revalidate: 60 } });
    data = res.data?.FaqContainerBlock?.items?.[0] ?? data;
  }

  return <div>...</div>;
}

Predictable queries and Graph CDN caching#

Optimizely Graph has its own CDN caching layer, separate from Next.js's fetch data cache. It caches responses by query string + variables. If two requests send exactly the same query with the same variables, the second is served from Graph's cache - no backend computation. The key to making this work is keeping queries predictable and stable.

Static queries

Navigation, Banner

Always same query + no variables → one Graph CDN entry, shared by every visitor on every page

Variation filter

CMS page variations

Query structure is fixed; value[] has a finite set of combinations → Graph CDN caches each variation separately

Per-user variables

userId, sessionId in query

Every request is unique → Graph CDN can never cache → every visit hits the Graph backend at full cost

Predictable vs. per-user query variables
// Graph CDN caches by (query string + variables).
// Static element queries are always identical → perfect cache hit rate.

// ✅ Navigation - same query, no variables → one CDN entry, shared by all visitors.
graphqlFetch(GET_NAVIGATION_QUERY, {}, { next: { revalidate: 300 } });

// ✅ Variation filter - structure is fixed; only value[] changes.
// Finite combinations ([], ["personal"], ["business"]) → Graph CDN caches each.
getContentByPath(url, {
  variation: { include: "SOME", value: activeVariations, includeOriginal: true },
});

// ❌ Anti-pattern: per-visitor data inside variables → every request is unique.
// Graph CDN can never cache this; every request hits the Graph backend.
graphqlFetch(PAGE_QUERY, {
  userId:    visitor.id,       // unique per visitor - cache miss every time
  sessionId: req.sessionId,    // random - makes CDN useless
});

// On the CMS page route, cache: false bypasses Graph CDN intentionally -
// the content is personalised and must be fresh on every request.
getContentByPath(url, { ...variationFilter, cache: false });

Batch key queries - N items, 1 query#

TeamGridBlock and TimelineBlock receive only reference keys from the page query. Rather than fetching one item at a time, they collect all keys and issue a single batch query using { key: { in: $keys } }. Results are mapped back to the original key order so display order is stable.

Batch query - N keys, 1 request

src/components/blocks/TeamGridBlock/index.tsx
// src/components/blocks/TeamGridBlock/index.tsx
// The page query returns TeamGridBlock.members as an array of reference keys.
// One batch query fetches all full member records - not one-per-key.

const MEMBERS_BY_KEYS_QUERY = `
  query TeamMembersByKeys($keys: [String!]) {
    TeamMemberBlock(where: { _metadata: { key: { in: $keys } } }) {
      items { ...TeamMemberBlockData }
    }
  }
  ${TEAM_MEMBER_FRAGMENT}
`;

async function loadMembers(keys: string[]): Promise<MemberData[]> {
  if (keys.length === 0) return [];
  const res = await graphqlFetch(MEMBERS_BY_KEYS_QUERY, { keys }, { next: { revalidate: 300 } });
  const items = res.data?.TeamMemberBlock?.items ?? [];

  // Map results back by key to preserve original display order.
  const byKey = new Map(items.map((i) => [i._metadata?.key, i]));
  return keys.map((k) => byKey.get(k)).filter(Boolean);
}

Naive loop - N keys, N requests

Anti-pattern - one fetch per key
// ❌ Naive: N queries for N members - sequential, uncacheable per request.
// 10 team members → 10 round-trips. Each fires independently,
// each has its own cache entry keyed by a single member key.

async function loadMembers(keys: string[]) {
  return Promise.all(
    keys.map((key) =>
      graphqlFetch(MEMBER_BY_KEY_QUERY, { key }, { next: { revalidate: 300 } })
    )
  );
}

indexingType: "disabled" fields#

Some fields are declared with indexingType: "disabled" in the content type definition. Graph does not index these fields - querying them in GraphQL always returns null, even when the editor has set a value. The field data only exists in the Visual Builder composition snapshot. In this codebase, TestimonialBlock.authorImage and AuthorBlock.avatar are both excluded from Graph queries for this reason.

TestimonialBlock - indexingType: disabled field returns null from Graph
// src/components/blocks/TestimonialBlock/index.tsx
// authorImage is declared with indexingType: "disabled".
// Graph does not index this field - querying it always returns null.

export const TestimonialBlockType = contentType({
  key: "TestimonialBlock",
  properties: {
    quote:       { type: "string" },
    authorName:  { type: "string" },
    authorRole:  { type: "string" },
    authorImage: { type: "contentReference", indexingType: "disabled" },
    //                                        ↑ excluded from Graph's index
  },
});

// getClient().getContent() fetches via Graph - authorImage will always be null.
// The value only exists in composition snapshots (when the block is placed
// inline in Visual Builder). Do not try to render it from a self-fetched item.
const testimonial = await getClient().getContent({ key });
// testimonial.authorImage → null

Key Things to Know#

  • One CMS page = one Graph request. getContentByPath() fetches the full composition automatically. Inline blocks need no additional queries.
  • For referenced content keys, use getClient().getContent({ key }) - no manual GraphQL query needed. Write custom graphqlFetch queries only for non-page data like navigation or content listings.
  • Always use the graphqlFetch wrapper - not raw fetch - so published/preview auth and ISR config are handled automatically.
  • Put static data in layout components with ISR. force-dynamic on the page route does not affect layout-level fetches - nav and banner stay cached.
  • Predictable query strings are Graph CDN-cacheable. Embedding per-user variables (userId, sessionId) makes every request a cache miss at the Graph layer.
  • @recursive(depth: N) fetches arbitrary tree depth in one round-trip. The depth cap prevents unbounded traversal.
  • type: "array" content areas inline-expand; type: "content" single references return metadata only - self-fetch if you need the data.
  • indexingType: "disabled" fields return null in Graph. Do not try to read them from getContent() - the value only exists in composition snapshots when the block is placed inline.
Source files3 files
src/lib/graphql/queries/GetNavigation.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";

// Public tree type — used by NestedNavMenu and the demo page

export interface NavNode {
  key: string;
  label: string;
  href: string;
  description?: string;
  openInNewTab?: boolean;
  children: NavNode[];
}

// Raw GraphQL response types

export interface RawNavItem {
  __typename?: string;
  _metadata?: { key?: string | null } | null;
  label?: string | null;
  // href is a ContentReference in Graph — url.default holds the URL string
  href?: { url?: { default?: string | null } | null } | null;
  description?: string | null;
  openInNewTab?: boolean | null;
  // Recursively typed — children are the same shape (any depth)
  children?: Array<RawNavItem | { __typename?: string }> | null;
}

interface GetNavigationResult {
  Navigation?: {
    items?: Array<{
      name?: string | null;
      navItems?: Array<RawNavItem | { __typename?: string }> | null;
    } | null> | null;
  } | null;
}

// Query
//
// A named fragment captures the repeated scalar fields so the nesting levels
// stay readable. GraphQL does not allow recursive fragments, so each level is
// written out explicitly — this makes the depth limit clear and intentional.

/**
 * The @recursive directive tells Optimizely Graph to apply this fragment to
 * the items in the decorated content area field at each nesting level, up to
 * the given depth. No need to repeat inline fragments manually — the directive
 * handles arbitrary depth with a single fragment definition.
 *
 * depth: 5 → NavRoot → L1 → L2 → L3 → L4 → L5
 */
// Display name written by seed-nav.ts — how editors identify the Navigation
// block in the Shared Blocks tab. Not used for querying (Graph doesn't index
// the name property for filtering).
export const NAV_BLOCK_NAME = "Navigation Menu";

export const GET_NAVIGATION_QUERY = /* GraphQL */ `
  fragment NavItemFields on _IContent {
    ... on NavigationItem {
      __typename
      _metadata { key }
      label
      href { url { default } }
      description
      openInNewTab
      children @recursive(depth: 5)
    }
  }

  query GetNavigation($locale: [Locales]) {
    # Newest first: deleted blocks can linger in the Graph index for a while
    # after a re-seed; ordering by lastModified guarantees the live block wins.
    Navigation(locale: $locale, orderBy: { _metadata: { lastModified: DESC } }, limit: 1) {
      items {
        name
        navItems {
          ...NavItemFields
        }
      }
    }
  }
`;

const GET_NAVIGATION_BY_KEY_QUERY = /* GraphQL */ `
  fragment NavItemFieldsByKey on _IContent {
    ... on NavigationItem {
      __typename
      _metadata { key }
      label
      href { url { default } }
      description
      openInNewTab
      children @recursive(depth: 5)
    }
  }

  query GetNavigationByKey($key: String!, $locale: [Locales]) {
    Navigation(
      where: { _metadata: { key: { eq: $key } } }
      locale: $locale
      limit: 1
    ) {
      items {
        name
        navItems {
          ...NavItemFieldsByKey
        }
      }
    }
  }
`;

// Response mapper

export function toNavNode(raw: RawNavItem): NavNode {
  return {
    key: raw._metadata?.key ?? "",
    label: raw.label ?? "",
    href: raw.href?.url?.default ?? "#",
    description: raw.description ?? undefined,
    openInNewTab: raw.openInNewTab ?? false,
    children: (raw.children ?? [])
      .filter((c): c is RawNavItem => (c as RawNavItem).__typename === "NavigationItem")
      .map(toNavNode),
  };
}

// Fetch helper

/**
 * Fetch the Navigation shared block and map its navItems into a typed NavNode
 * tree.
 *
 * By default queries by the sentinel name written by seed-nav.ts ("Seeded
 * Navigation") so re-seeding with a new CMS key is transparent. Pass `key` to
 * query a specific Navigation block (e.g. for preview).
 *
 * Cached for 5 minutes with a "navigation" tag — call
 * revalidateTag("navigation") from a publish webhook to bust on demand.
 *
 * Falls back to DEMO_NAV_DATA when the block can't be reached.
 */
export async function getNavigation(options: {
  previewToken?: string;
  key?: string;
  /** Content locale to fetch (defaults to "en"). Localized nav versions are
   *  created by scripts/seed-localization.ts. */
  locale?: string;
} = {}): Promise<{ tree: NavNode[]; fromCms: boolean }> {
  const { previewToken, key, locale = "en" } = options;

  try {
    const result = await graphqlFetch<GetNavigationResult>(
      key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
      key ? { key, locale: [locale] } : { locale: [locale] },
      previewToken
        ? { previewToken, cache: "no-store" }
        : { next: { revalidate: CACHE_TTL, tags: ["navigation"] } }
    );

    const root = result.data?.Navigation?.items?.[0];
    if (!root) return { tree: DEMO_NAV_DATA, fromCms: false };

    const items = (root.navItems ?? [])
      .filter((c): c is RawNavItem => (c as RawNavItem).__typename === "NavigationItem")
      .map(toNavNode);

    if (items.length === 0) return { tree: DEMO_NAV_DATA, fromCms: false };
    return { tree: items, fromCms: true };
  } catch {
    return { tree: DEMO_NAV_DATA, fromCms: false };
  }
}

// Static fallback nav — mirrors the CMS nav seeded by seed-nav.ts.
// Hrefs match the nested page URLs created by seed-content.ts.

export const DEMO_NAV_DATA: NavNode[] = [
  {
    key: 'products',
    label: 'Products',
    href: '/en/products',
    description: 'Our full product suite',
    children: [
      {
        key: 'cms',
        label: 'Content Management',
        href: '/cms',
        children: [
          { key: 'visual-builder',   label: 'Visual Builder',   href: '/visual-builder',   children: [] },
          { key: 'content-modeling', label: 'Content Modeling', href: '/content-modeling', children: [] },
          { key: 'localization',     label: 'Localization',     href: '/localization',     children: [] },
        ],
      },
      {
        key: 'feature-experimentation',
        label: 'Feature Experimentation',
        href: '/feature-experimentation',
        children: [
          { key: 'feature-flags',        label: 'Feature Flags',        href: '/feature-flags',        children: [] },
          { key: 'progressive-rollouts', label: 'Progressive Rollouts', href: '/progressive-rollouts', children: [] },
        ],
      },
      {
        key: 'web-experimentation',
        label: 'Web Experimentation',
        href: '/web-experimentation',
        children: [
          { key: 'visual-editor', label: 'Visual Editor', href: '/visual-editor', children: [] },
          { key: 'stats-engine',  label: 'Stats Engine',  href: '/stats-engine',  children: [] },
        ],
      },
      {
        key: 'analytics',
        label: 'Analytics',
        href: '/analytics',
        children: [
          { key: 'analytics-reports',      label: 'Reports & Dashboards', href: '/reports',      children: [] },
          { key: 'analytics-integrations', label: 'Integrations',         href: '/integrations', children: [] },
        ],
      },
    ],
  },
  {
    key: 'solutions',
    label: 'Solutions',
    href: '/en/solutions',
    children: [
      { key: 'ecommerce',  label: 'E-Commerce',        href: '/en/ecommerce',        children: [] },
      { key: 'media',      label: 'Media & Publishing', href: '/en/media-publishing', children: [] },
      { key: 'enterprise', label: 'Enterprise',         href: '/en/enterprise',       children: [] },
    ],
  },
  {
    key: 'resources',
    label: 'Resources',
    href: '/en/resources',
    children: [
      { key: 'docs',         label: 'Documentation', href: '/en/docs',         children: [] },
      { key: 'blog',         label: 'Blog',          href: '/en/blog',         children: [] },
      { key: 'case-studies', label: 'Case Studies',  href: '/en/case-studies', children: [] },
    ],
  },
  {
    key: 'developers',
    label: 'Developers',
    href: '/en/developers',
    children: [
      { key: 'api-reference', label: 'API Reference', href: '/en/api-reference', children: [] },
      { key: 'sdks',          label: 'SDKs',          href: '/en/sdks',          children: [] },
      { key: 'github',        label: 'GitHub',        href: 'https://github.com/episerver', openInNewTab: true, children: [] },
    ],
  },
  {
    key: 'company',
    label: 'Company',
    href: '/en/company',
    children: [
      { key: 'about',   label: 'About',   href: '/en/about',   children: [] },
      { key: 'careers', label: 'Careers', href: '/en/careers', children: [] },
      { key: 'contact', label: 'Contact', href: '/contact',    children: [] },
    ],
  },
];
src/components/blocks/FaqContainerBlock/index.tsx
import { contentType } from "@optimizely/cms-sdk";
import { OptimizelyComponent, getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { FaqItemBlockType } from "@/components/blocks/FaqItemBlock";
import { BlockErrorBoundary } from "@/components/cms/BlockErrorBoundary";

export const FaqContainerBlockType = contentType({
  key: "FaqContainerBlock",
  displayName: "FAQ Container",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],
  properties: {
    heading:    { type: "string",    displayName: "Heading",    indexingType: "searchable", isLocalized: true },
    subheading: { type: "string",    displayName: "Subheading", indexingType: "searchable", isLocalized: true },
    faqItems:   { type: "array", items: { type: "content", allowedTypes: [FaqItemBlockType] }, displayName: "FAQ Items" },
  },
});

interface FaqItemData {
  __typename?: string;
  question?: string | null;
  answer?: string | null;
}

interface FaqContainerData {
  heading?:    string | null;
  subheading?: string | null;
  faqItems?:   (FaqItemData | unknown)[] | null;
  __context?: { edit?: boolean } | null;
}

type FaqContainerBlockProps = FaqContainerData & {
  content?: FaqContainerData;
  displaySettings?: Record<string, string | boolean>;
};

export default function FaqContainerBlock(props: FaqContainerBlockProps) {
  const data: FaqContainerData = props.content ?? props;
  const { pa } = getPreviewUtils(data as any);

  return (
    <div data-component="FaqContainerBlock" className="py-16 max-w-3xl mx-auto px-8">
      {data.heading && (
        <h2
          {...pa("heading")}
          className="font-display text-3xl md:text-4xl font-extrabold mb-3 text-on-surface"
        >
          {data.heading}
        </h2>
      )}
      {data.subheading && (
        <p
          {...pa("subheading")}
          className="text-base text-on-surface-variant mb-8"
        >
          {data.subheading}
        </p>
      )}
      {data.faqItems && data.faqItems.length > 0 && (
        <div {...pa("faqItems")} className="space-y-2">
          {data.faqItems.map((item, i) => (
            <BlockErrorBoundary key={i}>
              <OptimizelyComponent content={item as any} />
            </BlockErrorBoundary>
          ))}
        </div>
      )}
    </div>
  );
}
src/components/blocks/TeamGridBlock/index.tsx
import { contentType, getClient } from "@optimizely/cms-sdk";
import { OptimizelyComponent, getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { TeamMemberBlockType } from "@/components/blocks/TeamMemberBlock";
import { BlockErrorBoundary } from "@/components/cms/BlockErrorBoundary";
import { CACHE_TTL } from "@/lib/optimizely/client";

export const TeamGridBlockType = contentType({
  key: "TeamGridBlock",
  displayName: "Team Grid",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],
  properties: {
    heading:    { type: "string", displayName: "Heading",    indexingType: "searchable", isLocalized: true },
    subheading: { type: "string", displayName: "Subheading", isLocalized: true },
    members:    {
      type: "array",
      displayName: "Members",
      items: { type: "contentReference", allowedTypes: [TeamMemberBlockType] },
    },
  },
});

// See TimelineBlock for the three shapes Graph returns for contentReference
// arrays. extractKey unifies them.
type MemberRef =
  | string
  | { key?: string | null; _metadata?: { key?: string | null } | null };

interface MemberData {
  __typename?: string;
  _metadata?: { key?: string | null } | null;
  name?: string | null;
  role?: string | null;
  bio?:  string | null;
  linkedinUrl?: string | null;
  photo?: { _metadata?: { url?: { default?: string | null } | null } | null } | null;
}

interface TeamGridData {
  heading?:    string | null;
  subheading?: string | null;
  members?:    Array<MemberRef | null> | null;
  __context?: { edit?: boolean } | null;
}

function extractKey(ref: MemberRef | null | undefined): string | null {
  if (!ref) return null;
  if (typeof ref === "string") {
    const m = /cms:\/\/content\/([a-f0-9-]+)/i.exec(ref);
    return m?.[1] ?? null;
  }
  return ref.key ?? ref._metadata?.key ?? null;
}

type TeamGridBlockProps = TeamGridData & {
  content?: TeamGridData;
  displaySettings?: Record<string, string | boolean>;
};

async function loadMembers(keys: string[]): Promise<MemberData[]> {
  if (keys.length === 0) return [];
  const results = await Promise.all(
    keys.map((key) =>
      getClient().getContent({ key }, { next: { revalidate: CACHE_TTL } } as any).catch(() => null)
    )
  );
  return results.filter((item): item is MemberData => Boolean(item));
}

export default async function TeamGridBlock(props: TeamGridBlockProps) {
  const data = props.content ?? props;
  const { pa } = getPreviewUtils(data as any);

  const keys = (data.members ?? [])
    .map(extractKey)
    .filter((k): k is string => Boolean(k));
  const members = await loadMembers(keys);

  return (
    <section data-component="TeamGridBlock" className="py-20 max-w-7xl mx-auto px-8">
      <div className="text-center mb-12 max-w-2xl mx-auto">
        {data.heading && (
          <h2 {...pa("heading")} className="font-display text-3xl md:text-4xl font-extrabold text-on-surface mb-3">
            {data.heading}
          </h2>
        )}
        {data.subheading && (
          <p {...pa("subheading")} className="text-base text-on-surface-variant">
            {data.subheading}
          </p>
        )}
      </div>
      {members.length > 0 && (
        <div {...pa("members")} className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
          {members.map((m, i) => (
            <BlockErrorBoundary key={i}>
              <OptimizelyComponent content={m as any} />
            </BlockErrorBoundary>
          ))}
        </div>
      )}
    </section>
  );
}