Developer Demo

Navigation Strategies

Four ways to drive site navigation from Optimizely Graph - each with live data fetched server-side so you can see exactly what each query returns.

S1 · Manual block + @recursiveS2 · Include-in-nav flagS3 · _ancestors hierarchyS4 · Content type query

1Manual Navigation Block#

A NavigationRoot shared block, living in the Shared Blocks tab under "For All Applications", holds the entire tree. Editors build it once and maintain it independently of page content. Optimizely Graph's @recursive directive fetches all 5 levels in one round-trip.

Shared blocks vs content items

The CMS distinguishes two kinds of items. Shared blocks live in the Shared Blocks tab (nested under "For All Applications" or "For This Page") and are what editors bind into content areas like navItems and children. Content items live in the Content Manager tab and are the targets of contentReference properties like each item's href. A block created outside the shared-blocks folder is a plain content item - it can still be referenced, but editors will never find it in the Shared Blocks tab.

✓ Live from CMS

The @recursive Query

One fragment applied at every nesting level up to the given depth. No repeated inline fragments.

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
        }
      }
    }
  }

Content Types & CMS Setup

NavigationItem uses allowedTypes: ["_self"] so only other NavigationItems can be nested inside it. SDK docs ↗

// NavigationItem - self-referential content area
export const NavigationItemType = contentType({
  key: "NavigationItem",
  baseType: "_component",
  properties: {
    label:       { type: "string",  displayName: "Label" },
    href:        { type: "string",  displayName: "URL" },
    description: { type: "string",  displayName: "Description" },
    openInNewTab: { type: "boolean", displayName: "Open in New Tab" },
    children: {
      type: "array",
      displayName: "Child Items",
      items: { type: "content", allowedTypes: ["_self"] },
    },
  },
});

// NavigationRoot - singleton populated once by editors
export const NavigationRootType = contentType({
  key: "NavigationRoot",
  baseType: "_component",
  properties: {
    name: { type: "string", displayName: "Name" },
    navItems: {
      type: "array",
      displayName: "Top-level Items",
      items: { type: "content", allowedTypes: [NavigationItemType] },
    },
  },
});
CMS setup walkthrough
1. Create one "Navigation Root" as a SHARED BLOCK: open the Shared Blocks
   tab, select "For All Applications", and create the block there (e.g.
   "Main Nav"). Blocks created elsewhere are plain content items and will
   not appear in the Shared Blocks tab or in content-area pickers.

2. Open it and add NavigationItem entries to the "Top-level Items" content
   area. Content areas bind shared blocks; each NavigationItem's "URL" is a
   contentReference that points at a content item (a page) from the
   Content Manager tab.

3. Nest down to 5 levels via each item's "Child Items" content area - the
   @recursive directive handles any depth:

   NavigationRoot  (shared block)
   └── navItems  (content area)
       └── NavigationItem  (L1, shared block)
           └── children  (content area)
               └── NavigationItem  (L2, shared block)

4. Publish. The query fetches all levels in one round-trip.
   On-demand revalidation: revalidateTag("navigation") from a webhook.

Strengths

  • Full editorial control - reorder, rename, add non-page items (labels, external links)
  • Supports deep trees (up to depth 10 with @recursive)
  • Navigation lifecycle is independent of page lifecycle
  • One content item to cache and invalidate

Trade-offs

  • Editors maintain a second object - nav can drift from actual pages
  • New pages must be manually wired into the tree
  • Setup cost: NavigationRoot and items must be seeded or built in the CMS UI

Best for: most production sites - when editors need precise control over labels, ordering, and the ability to include items that don't map to real pages (e.g. external links, section headings).


2Include in Navigation Flag#

Add three properties to your page content type: includeInNavigation, navLabel, and navOrder. The query returns all opted-in pages in one round-trip - then a URL-prefix tree builder groups children under their parents on the server. Any page that sets the flag, at any depth, is automatically nested under the nearest ancestor that also has the flag.

Content type additions

// Add three properties to your page content type:
export const LandingPageType = contentType({
  key: "TraditionalPage",
  baseType: "_page",
  properties: {
    // ... existing fields ...
    includeInNavigation: {
      type: "boolean",
      displayName: "Include in Navigation",
      indexingType: "queryable",   // ← enables where-filter on this field
    },
    navLabel: {
      type: "string",
      displayName: "Navigation Label",
    },
    navOrder: {
      type: "integer",
      displayName: "Nav Order",
      indexingType: "queryable",
    },
  },
});

Graph query

query GetNavigationFromFlags {
    TraditionalPage(
      where: { includeInNavigation: { eq: true } }
      orderBy: { navOrder: ASC }
      limit: 100
    ) {
      items {
        _metadata { key url { default } }
        navLabel
        navOrder
      }
    }
  }

Server-side tree builder

// Server-side tree builder - no extra queries needed.
// All opted-in pages come back flat; parent = longest URL prefix match.
function buildTree(items) {
  const sorted = items.sort((a, b) => a.href.length - b.href.length);
  const nodeMap = new Map();
  const roots = [];

  for (const item of sorted) {
    const node = { ...item, children: [] };
    nodeMap.set(item.href, node);

    // Longest other opted-in URL that is a strict prefix → direct parent
    const parent = sorted
      .filter(p => p.href !== item.href && item.href.startsWith(p.href))
      .sort((a, b) => b.href.length - a.href.length)[0];

    if (parent && nodeMap.has(parent.href)) {
      nodeMap.get(parent.href).children.push(node);
    } else {
      roots.push(node);
    }
  }
  return roots;
}

Live result

✓ Live from CMS
14 items2 levels deep8 root nodes

Tree built from URL prefixes server-side - no extra queries. Child pages nest under whichever ancestor also has the flag set.

Strengths

  • Nav auto-syncs: publish a page with the flag → it appears in nav
  • Nested trees supported - child pages nest under opted-in parents automatically
  • No separate navigation object to maintain
  • Impossible for nav and page to get out of sync
  • Easy to audit: filter pages by includeInNavigation in the CMS UI

Trade-offs

  • Cannot include non-page items (external links, section labels)
  • Editors must set the flag on every page they want in the nav
  • Tree depth depends on URL structure - URL must mirror desired nesting

Best for: sites where nav items map 1:1 to CMS pages and editors want nav membership controlled per-page without a separate object to maintain.


3Folder / Page Hierarchy#

Use Optimizely Graph's _ancestors filter to mirror the CMS content tree. Two steps: find the parent page by its known URL, then query all _Page items whose _ancestors array contains that key. The result below shows the live children of the Personal Banking section.

How it works

// Two-step query:
// 1. Find parent page by its known URL
// 2. Query all _Page items where _ancestors contains the parent key

const parent = await graphqlFetch(GET_PARENT_KEY_QUERY);
const parentKey = parent._Page.items[0]._metadata.key;

const children = await graphqlFetch(GET_CHILDREN_BY_ANCESTOR_QUERY, { parentKey });
// → returns Current Account, Savings, etc.

Children query

query GetChildrenByAncestor($parentKey: String!) {
    _Page(
      where: { _ancestors: { eq: $parentKey } }
      orderBy: { _metadata: { sortOrder: ASC } }
      limit: 20
    ) {
      items {
        _metadata { displayName url { default } }
      }
    }
  }

Live result - children of Personal Banking

◎ Fallback data - run seed-content.ts
parent →Personal Banking/en/personal/
  • Current Account
    /en/personal/current-account/
  • Savings
    /en/personal/savings/

The _ancestors filter returns all descendants at any depth. Apply a URL depth check to retrieve only direct children if needed.

Strengths

  • Zero editorial overhead - create and publish a page → it appears in nav
  • Navigation exactly mirrors URL/folder structure
  • No extra fields or objects to maintain

Trade-offs

  • All published pages appear - no opt-out mechanism without additional filtering
  • Ordering follows CMS sort order, not editorial preference
  • Cannot include non-page items or override labels
  • Requires two serial Graph requests (parent key lookup + children query)

Best for: documentation sites, microsites, or any site where the URL hierarchy should equal the navigation hierarchy with zero maintenance.


4Content Type–Driven Section Menu#

Query a specific content type directly - every published item of that type becomes a nav candidate. Ideal for section menus like "all articles" or "all case studies" inside a larger manually-framed top nav. The scope is enforced by the type itself, not by flags or folder position.

How it works

// Query ArticlePage directly - the content type IS the navigation scope.
// No flag, no hierarchy: every published ArticlePage is a nav candidate.
// orderBy and limit control what surfaces.

const { items } = await graphqlFetch(GET_ARTICLE_NAVIGATION_QUERY);
// items: [{ title, category, _metadata.url.default }, ...]

Graph query

query GetArticleNavigation {
    ArticlePage(
      orderBy: { publishDate: DESC }
      limit: 6
    ) {
      items {
        _metadata { url { default } }
        title
        category
      }
    }
  }

Live result - ArticlePage items

✓ Live from CMS
  • Mortgage rates in 2026: what's actually moving them
    /en/insights/articles/mortgage-rates-explained/
  • Mortgage rates in 2026: what's actually moving them
    /insights/articles/mortgage-rates-explained/
  • Saving for your first home: a 5-step plan that actually works
    /en/insights/articles/saving-for-first-home/
  • Saving for your first home: a 5-step plan that actually works
    /insights/articles/saving-for-first-home/
  • Business banking essentials for founders in year one
    /insights/articles/business-banking-essentials/
  • Business banking essentials for founders in year one
    /en/insights/articles/business-banking-essentials/

Category is shown as a pill. Hover reveals the article URL. Add where: { category: { eq: "personal-finance" } } to scope a subsection.

Strengths

  • Perfectly scoped - the content type boundary defines what appears
  • Great for megamenu section panels populated from real content
  • No editorial overhead beyond authoring the pages themselves
  • Supports rich filtering: by category, tag, date, or any queryable field

Trade-offs

  • Only items of that specific type appear - can't mix types in one query
  • Requires a separate query per section type
  • No override mechanism for labels or ordering without dedicated fields

Best for: section mega-menus ("all our articles", "all case studies"), or anywhere content-type membership is a natural navigation boundary.

Source files4 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/lib/graphql/queries/GetNavigationFromFlags.ts
// Demo reference implementation for /demo/navigation - production nav uses GetNavigation.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";
import type { NavNode } from "./GetNavigation";

export interface FlagNavResult {
  tree: NavNode[];
  fromCms: boolean;
}

export const GET_NAVIGATION_FROM_FLAGS_QUERY = /* GraphQL */ `
  query GetNavigationFromFlags {
    TraditionalPage(
      where: { includeInNavigation: { eq: true } }
      orderBy: { navOrder: ASC }
      limit: 100
    ) {
      items {
        _metadata { key url { default } }
        navLabel
        navOrder
      }
    }
  }
`;

interface RawFlagItem {
  _metadata?: { key?: string; url?: { default?: string } };
  navLabel?: string;
  navOrder?: number;
}

function buildTree(items: RawFlagItem[]): NavNode[] {
  // Sort shortest URL first so parents are always processed before children.
  const sorted = [...items]
    .filter((i) => i.navLabel && i._metadata?.url?.default && i._metadata?.key)
    .sort((a, b) => {
      const lenDiff = (a._metadata!.url!.default!.length) - (b._metadata!.url!.default!.length);
      return lenDiff !== 0 ? lenDiff : (a.navOrder ?? 0) - (b.navOrder ?? 0);
    });

  const nodeMap = new Map<string, NavNode>();
  const roots: NavNode[] = [];

  for (const item of sorted) {
    const href = item._metadata!.url!.default!;
    const node: NavNode = {
      key: item._metadata!.key!,
      label: item.navLabel!,
      href,
      children: [],
    };
    nodeMap.set(href, node);

    // Direct parent = longest other URL that is a strict prefix of this URL.
    const parent = sorted
      .filter((p) => {
        const ph = p._metadata?.url?.default;
        return ph && ph !== href && href.startsWith(ph);
      })
      .sort((a, b) => b._metadata!.url!.default!.length - a._metadata!.url!.default!.length)[0];

    if (parent?._metadata?.url?.default && nodeMap.has(parent._metadata.url.default)) {
      nodeMap.get(parent._metadata.url.default)!.children.push(node);
    } else {
      roots.push(node);
    }
  }

  return roots;
}

const FALLBACK_TREE: NavNode[] = [
  {
    key: "fallback-home",
    label: "Home",
    href: "/",
    children: [],
  },
  {
    key: "fallback-personal",
    label: "Personal Banking",
    href: "/en/personal/",
    children: [
      { key: "fallback-ca",      label: "Current Account", href: "/en/personal/current-account/", children: [] },
      { key: "fallback-savings", label: "Savings",          href: "/en/personal/savings/",         children: [] },
    ],
  },
  {
    key: "fallback-business",
    label: "Business",
    href: "/en/business/",
    children: [
      { key: "fallback-bb", label: "Business Banking", href: "/en/business/business-banking/", children: [] },
    ],
  },
  { key: "fallback-mortgages", label: "Mortgages", href: "/en/mortgage/",  children: [] },
  { key: "fallback-about",     label: "About",     href: "/en/about/",     children: [] },
];

export async function getNavigationFromFlags(): Promise<FlagNavResult> {
  try {
    const result = await graphqlFetch<{
      TraditionalPage?: { items?: RawFlagItem[] };
    }>(GET_NAVIGATION_FROM_FLAGS_QUERY, {}, { next: { revalidate: CACHE_TTL, tags: ["navigation"] } });

    const raw = result.data?.TraditionalPage?.items ?? [];
    const tree = buildTree(raw);

    if (tree.length === 0) return { tree: FALLBACK_TREE, fromCms: false };
    return { tree, fromCms: true };
  } catch {
    return { tree: FALLBACK_TREE, fromCms: false };
  }
}
src/lib/graphql/queries/GetNavigationFromHierarchy.ts
// Demo reference implementation for /demo/navigation - production nav uses GetNavigation.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";

export interface HierarchyNavItem {
  label: string;
  href: string;
}

export interface HierarchyNavResult {
  parentLabel: string;
  parentHref: string;
  items: HierarchyNavItem[];
  fromCms: boolean;
}

const GET_PARENT_KEY_QUERY = /* GraphQL */ `
  query GetPersonalBankingKey {
    _Page(
      where: { _metadata: { url: { default: { eq: "/en/personal/" } } } }
      limit: 1
    ) {
      items {
        _metadata { key displayName }
      }
    }
  }
`;

export const GET_CHILDREN_BY_ANCESTOR_QUERY = /* GraphQL */ `
  query GetChildrenByAncestor($parentKey: String!) {
    _Page(
      where: { _ancestors: { eq: $parentKey } }
      orderBy: { _metadata: { sortOrder: ASC } }
      limit: 20
    ) {
      items {
        _metadata { displayName url { default } }
      }
    }
  }
`;

const FALLBACK_RESULT: HierarchyNavResult = {
  parentLabel: "Personal Banking",
  parentHref: "/en/personal/",
  items: [
    { label: "Current Account",  href: "/en/personal/current-account/" },
    { label: "Savings",          href: "/en/personal/savings/" },
  ],
  fromCms: false,
};

export async function getNavigationFromHierarchy(): Promise<HierarchyNavResult> {
  try {
    const parentResult = await graphqlFetch<{
      _Page?: { items?: Array<{ _metadata?: { key?: string; displayName?: string } }> };
    }>(GET_PARENT_KEY_QUERY, {}, { next: { revalidate: CACHE_TTL, tags: ["navigation"] } });

    const parent = parentResult.data?._Page?.items?.[0];
    const parentKey = parent?._metadata?.key;
    const parentLabel = parent?._metadata?.displayName ?? "Personal Banking";

    if (!parentKey) return FALLBACK_RESULT;

    const childResult = await graphqlFetch<{
      _Page?: { items?: Array<{ _metadata?: { displayName?: string; url?: { default?: string } } }> };
    }>(
      GET_CHILDREN_BY_ANCESTOR_QUERY,
      { parentKey },
      { next: { revalidate: CACHE_TTL, tags: ["navigation"] } }
    );

    const raw = childResult.data?._Page?.items ?? [];
    const items: HierarchyNavItem[] = raw
      .filter((i) => i._metadata?.url?.default)
      .map((i) => ({
        label: i._metadata?.displayName ?? i._metadata?.url?.default ?? "",
        href: i._metadata!.url!.default!,
      }));

    if (items.length === 0) return FALLBACK_RESULT;

    return {
      parentLabel,
      parentHref: "/en/personal/",
      items,
      fromCms: true,
    };
  } catch {
    return FALLBACK_RESULT;
  }
}
src/lib/graphql/queries/GetNavigationFromContentType.ts
// Demo reference implementation for /demo/navigation - production nav uses GetNavigation.ts
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";

export interface ContentTypeNavItem {
  label: string;
  href: string;
  meta: string;
}

export interface ContentTypeNavResult {
  items: ContentTypeNavItem[];
  fromCms: boolean;
}

export const GET_NAVIGATION_FROM_CONTENT_TYPE_QUERY = /* GraphQL */ `
  query GetArticleNavigation {
    ArticlePage(
      orderBy: { publishDate: DESC }
      limit: 6
    ) {
      items {
        _metadata { url { default } }
        title
        category
      }
    }
  }
`;

const CATEGORY_LABELS: Record<string, string> = {
  "personal-finance": "Personal Finance",
  "business-banking": "Business Banking",
  "investments":      "Investments",
  "market-insights":  "Market Insights",
};

const FALLBACK_ITEMS: ContentTypeNavItem[] = [
  { label: "Guide to ISAs",             href: "/en/insights/guide-to-isas/",          meta: "Personal Finance" },
  { label: "Business Banking Basics",   href: "/en/insights/business-banking-basics/", meta: "Business Banking" },
  { label: "5 Savings Tips for 2025",   href: "/en/insights/savings-tips/",           meta: "Personal Finance" },
];

export async function getNavigationFromContentType(): Promise<ContentTypeNavResult> {
  try {
    const result = await graphqlFetch<{
      ArticlePage?: {
        items?: Array<{
          _metadata?: { url?: { default?: string } };
          title?: string;
          category?: string;
        }>;
      };
    }>(GET_NAVIGATION_FROM_CONTENT_TYPE_QUERY, {}, { next: { revalidate: CACHE_TTL, tags: ["page"] } });

    const raw = result.data?.ArticlePage?.items ?? [];
    const items: ContentTypeNavItem[] = raw
      .filter((i) => i.title && i._metadata?.url?.default)
      .map((i) => ({
        label: i.title!,
        href: i._metadata!.url!.default!,
        meta: CATEGORY_LABELS[i.category ?? ""] ?? i.category ?? "",
      }));

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