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)
# 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)
// ❌ 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:
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.
// 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
#
# @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
# 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
// 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
// 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
// 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
// ❌ 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.
// 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
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: [] },
],
},
];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>
);
}
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>
);
}