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)
# 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: 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
#
# @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 Graph queries 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
},
});
// 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 → nullKey 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 customgraphqlFetchqueries 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
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: [] },
],
},
];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>
);
}
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>
);
}