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.
1Manual Navigation Block#
A standalone NavigationRoot content item 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.
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 {
Navigation(limit: 1) {
items {
name
navItems {
...NavItemFields
}
}
}
}Content Types & CMS Setup
NavigationItem uses allowedTypes: ["_self"] so only other NavigationItems can be nested inside it.
// 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" item in the CMS (e.g. "Main Nav").
2. Open it and add NavigationItem entries to the "Top-level Items" content area.
Each NavigationItem has a "Child Items" content area for the next level.
3. Nest down to 5 levels — the @recursive directive handles any depth:
NavigationRoot
└── navItems (content area)
└── NavigationItem (L1)
└── children (content area)
└── NavigationItem (L2)
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- Personal Banking/en/nav-flag-personal/2L1
- Business/en/nav-flag-business/1L1
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- 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
◎ Fallback data — run seed-nav-strategy-demo.ts- Guide to ISAsPersonal Finance/en/insights/guide-to-isas/
- Business Banking BasicsBusiness Banking/en/insights/business-banking-basics/
- 5 Savings Tips for 2025Personal Finance/en/insights/savings-tips/
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
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 { graphqlFetch } 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: 300, 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 };
}
}
import { graphqlFetch } 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: 300, 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: 300, 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;
}
}
import { graphqlFetch } 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: 60, 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 };
}
}