Variation #2Continue reading...

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-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 dataNavigation trees, site banners, referral lists — data that exists independently of any page. These live in src/lib/graphql/queries/ and are called from layout components.
Self-fetching componentsBlocks 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 resolutionBlocks 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: 60  → cached */}
        <NavigationHeader />  {/* graphqlFetch, revalidate: 300 → 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: 60

Tag: banner

Same query for all visitors

NavigationHeader

Cache: revalidate: 300

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 area → Graph 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 reference → Graph 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 their fragments for this reason.

TestimonialBlock — indexingType: disabled field omitted from fragment
// 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
  },
});

// src/components/blocks/TestimonialBlock/fragment.ts
// authorImage is intentionally omitted from the fragment.
// Querying it would always return null, even when the editor has set it.
export const TESTIMONIAL_FRAGMENT = `
  fragment TestimonialBlockData on TestimonialBlock {
    __typename
    _metadata { key version }
    quote
    authorName
    authorRole
    # authorImage ← omitted: indexingType: "disabled" means Graph returns null
  }
`;

Key Things to Know#

  • One CMS page = one Graph request. The SDK auto-generates a query with all registered block type spreads. No per-block fetches at render time.
  • Write custom queries only when needed: non-page data (nav, banner), self-fetching blocks that receive only a reference key, and batch reference resolution.
  • 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. Omit them from fragments entirely; the data only exists in composition snapshots.
Source files3 files
src/lib/graphql/queries/GetNavigation.ts
import { graphqlFetch } 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
 */
// Sentinel name written by seed-nav.ts — used as the CMS displayName so editors
// can identify the seeded Navigation block in the CMS UI. Not used for querying
// (Graph doesn't index the name property for filtering).
export const SEEDED_NAV_NAME = "Seeded Navigation";

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 {
    Navigation(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!) {
    Navigation(
      where: { _metadata: { key: { eq: $key } } }
      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;
} = {}): Promise<{ tree: NavNode[]; fromCms: boolean }> {
  const { previewToken, key } = options;

  try {
    const result = await graphqlFetch<GetNavigationResult>(
      key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
      key ? { key } : {},
      previewToken
        ? { previewToken, cache: "no-store" }
        : { next: { revalidate: 300, 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 { graphqlFetch } from "@/lib/optimizely/client";
import { FaqItemBlockType } from "@/components/blocks/FaqItemBlock";

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

const FETCH_QUERY = /* GraphQL */`{
  FaqContainerBlock(limit: 1) {
    items {
      heading
      subheading
      faqItems {
        __typename
        ... on FaqItemBlock { question answer }
      }
    }
  }
}`;

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 async function FaqContainerBlock(props: FaqContainerBlockProps) {
  let data: FaqContainerData = props.content ?? props;

  // When featuredBlock resolves as a generic _Content reference (Graph doesn't
  // inline-expand standalone content references on TraditionalPage), self-fetch
  // the FAQ container data directly from Graph.
  if (!data.heading) {
    const res = await graphqlFetch<{ FaqContainerBlock: { items: FaqContainerData[] } }>(
      FETCH_QUERY,
      {},
      { next: { revalidate: 60 } }
    );
    data = res.data?.FaqContainerBlock?.items?.[0] ?? data;
  }

  const { pa } = getPreviewUtils(data as any);

  return (
    <div 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) => (
            <OptimizelyComponent key={i} content={item as any} />
          ))}
        </div>
      )}
    </div>
  );
}
src/components/blocks/TeamGridBlock/index.tsx
import { contentType } from "@optimizely/cms-sdk";
import { OptimizelyComponent, getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { graphqlFetch } from "@/lib/optimizely/client";
import { TeamMemberBlockType } from "@/components/blocks/TeamMemberBlock";
import { TEAM_MEMBER_FRAGMENT } from "@/components/blocks/TeamMemberBlock/fragment";

export const TeamGridBlockType = contentType({
  key: "TeamGridBlock",
  displayName: "Team Grid",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],
  properties: {
    heading:    { type: "string", displayName: "Heading" },
    subheading: { type: "string", displayName: "Subheading" },
    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>;
};

const MEMBERS_BY_KEYS_QUERY = /* GraphQL */ `
  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<{ TeamMemberBlock?: { items?: MemberData[] } }>(
    MEMBERS_BY_KEYS_QUERY,
    { keys },
    { next: { revalidate: 300 } }
  );
  const items = res.data?.TeamMemberBlock?.items ?? [];
  const byKey = new Map(items.map((i) => [i._metadata?.key, i]));
  return keys
    .map((k) => byKey.get(k))
    .filter((i): i is MemberData => Boolean(i));
}

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 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) => (
            <OptimizelyComponent key={i} content={m as any} />
          ))}
        </div>
      )}
    </section>
  );
}