Developer Demo

Error Handling & Graceful Degradation

How this project handles missing content, Graph failures, and block-level errors - without blanking the page or surfacing stack traces to visitors.

How a Graph query fails#

request() behaves differently depending on where the error occurs. A failed connection or any non-2xx status throws - a typed GraphContentResponseError when the body carries errors[], otherwise GraphHttpResponseError, both carrying the status and the offending query. A 200 that carries errors[] does not throw: the client returns json.data and discards the errors, so a partially-resolved query looks exactly like empty content. That third case is the one that wastes an afternoon. SDK docs ↗

The three failure modes of graphClient().request()
// How graphClient().request() fails - three distinct cases.
// Paraphrased from the SDK: node_modules/@optimizely/cms-sdk/dist/esm/graph/index.js

// Case 1: fetch() itself rejects (DNS failure, connection refused, bad Graph URL)
//   Result: THROWS OptimizelyGraphError, with the original TypeError as .cause
//   Caller responsibility: try-catch if absence is acceptable

// Case 2: non-2xx response (Graph down, invalid API key, rate limit, bad query)
//   Result: THROWS - GraphContentResponseError when the body carries errors[],
//           otherwise GraphHttpResponseError. Both include the status and the
//           { query, variables } that caused it, which is what you want in a log.
if (!response.ok) {
  const json = JSON.parse(text);           // may not be JSON at all
  if (json.errors) throw new GraphContentResponseError(json.errors, { status, request });
  throw new GraphHttpResponseError(response.statusText, { status, request });
}

// Case 3: 200 OK carrying errors[] (a partially-resolved query)
//   Result: does NOT throw, and the errors are DISCARDED - request() returns
//           json.data only. Fields that failed to resolve arrive as null with
//           no explanation anywhere.
const json = await response.json();
return json.data;        // ← errors[] never reaches the caller

// Case 3 is the trap: a partial failure is indistinguishable from genuinely
// empty content. If a field is mysteriously null, query it directly against
// Graph to see the errors[] the client swallowed.

HTTP error (throws)

When: Graph is down, network timeout, invalid API key, rate limit (429)

Result: Error propagates up. Unhandled → Next.js 500 page.

Handle it: Wrap in try-catch and return fallback data, or let it 500.

GraphQL error (returns)

When: Unknown field, type mismatch, partial data with permission error on one field

Result: { data: null, errors: [...] } returned. Does not throw.

Handle it: Check result.errors if needed. Always handle data: null with ?? fallback.

404 vs. 500 - notFound() only for missing content#

notFound() is for content that genuinely doesn't exist - the URL has never had content, or the editor unpublished it. It triggers Next.js to render the nearest not-found.tsx (HTTP 404). A Graph error is not a 404 - the content may exist but the service is temporarily unavailable. Calling notFound() on catch would falsely tell search engines the URL is gone.

src/app/[[...slug]]/page.tsx - when notFound() is called
// src/app/[[...slug]]/page.tsx - the catch-all CMS route
//
// notFound() is called only when BOTH lookup strategies come up empty.
// This triggers Next.js to render the nearest not-found.tsx page (404).
// It is NOT called on Graph errors - those propagate as 500s.

import { notFound } from "next/navigation";

// Strategy 1: URL-based lookup via getContentByPath
let page = null;
for (const candidateUrl of candidateUrls) {
  const [result] = await client.getContentByPath(candidateUrl, variationFilter);
  if (result) { page = result; break; }
}

// Strategy 2: fallback key query via a raw request()
if (!page) {
  const res = await graphClient().request(KEY_QUERY, { url });
  page = res?._Page?.items?.[0] ?? null;
}

// Only if both return nothing → 404
if (!page) notFound();

// ✅ 404 - content genuinely doesn't exist
// ❌ DON'T call notFound() on catch - a Graph error should be a 500, not a 404

Per-component graceful degradation#

Layout components (banner, navigation, footer) live in the root layout and run on every page. An unhandled error in any one of them blanks the entire site. Wrap their Graph calls in try-catch and return null on failure. The component renders nothing - the page still works.

getSiteBanner() - try-catch returns null on error
// Per-component graceful degradation - never crash the page for missing data.
//
// Pattern: wrap the Graph call in try-catch and return null (render nothing)
// rather than throwing. The component is optional - its absence is acceptable.
// A broken banner should not blank the whole site.

// src/lib/graphql/queries/GetSiteBanner.ts
async function fetchSiteBanner(locale: string) {
  "use cache";
  cacheTag("banner");
  cacheLife({ stale: 300, revalidate: 3600, expire: 86400 });
  try {
    return await graphClient().request(GET_SITE_BANNER_QUERY, { locale: [locale] });
  } catch {
    return {};   // Graph down → empty → caller's fallback path runs
  }
}

export async function getSiteBanner(locale = "en"): Promise<SiteBannerItem | null> {
  try {
    const data = await fetchSiteBanner(locale);
    return data?.SiteBanner?.items?.[0] ?? null;  // null if empty
  } catch {
    return null;   // mapping errors only - Graph errors were handled above
  }
}
// The catch MUST go INSIDE the cached function, which is the opposite of the
// instinct. A rejected promise inside "use cache" fails static generation
// outright and no try/catch at the call site can rescue it.
//
// The cost is real and worth stating: a Graph outage during a render gets
// written into the cache entry and served for the rest of the revalidate
// window. Where an hour of "no banner" is worse than an hour of stale banner,
// shorten cacheLife rather than moving the catch.

// src/components/layout/GlobalBanner/index.tsx
export default async function GlobalBanner() {
  const banner = await getSiteBanner();
  if (!banner?.enabled || !banner.message) return null;  // silent absence
  return <div>{banner.message}</div>;
}
Hardcoded fallback for critical components (nav)
// Hardcoded fallback data for critical layout components.
//
// Navigation and footer are essential - if Graph is unavailable,
// return a minimal hardcoded version rather than nothing.
// This keeps the site usable during outages.

const FALLBACK_NAV = {
  items: [
    { label: "Home",     href: "/" },
    { label: "About",    href: "/en/about" },
    { label: "Contact",  href: "/en/contact" },
  ],
};

async function fetchNavigationCached(locale: string) {
  "use cache";
  cacheTag("navigation");
  cacheLife({ stale: 300, revalidate: 3600, expire: 86400 });
  try {
    return await graphClient().request(GET_NAV_QUERY, { locale: [locale] });
  } catch (error) {
    // Inside the boundary, because a rejection here would fail the prerender.
    console.error("[fetchNavigationCached] Graph query failed:", error);
    return {};
  }
}

export async function getNavigation(locale = "en") {
  try {
    const data = await fetchNavigationCached(locale);
    return data?.Navigation?.items?.[0] ?? FALLBACK_NAV;
  } catch (error) {
    console.error("[getNavigation] Falling back:", error);
    return FALLBACK_NAV;   // empty result or mapping error → hardcoded nav
  }
}

// For pages: returning null from a layout component
// is preferable to an unhandled exception in the root layout.

Block-level error boundaries#

A render error in one block should not blank the whole page. React Error Boundaries catch errors in their subtree and render a fallback instead. Wrap each block in the composition renderer with a boundary - if a TeamGridBlock throws during render, the rest of the page continues to display normally.

BlockErrorBoundary - isolate render errors per block
// React Error Boundaries catch render errors in subtrees.
// Use them to isolate block-level failures - a broken chart block
// should not blank the entire page composition.

// src/components/cms/BlockErrorBoundary.tsx
"use client";
import { Component, type ReactNode } from "react";

export class BlockErrorBoundary extends Component<
  { children: ReactNode; fallback?: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: Error) {
    console.error("[Block render error]", error);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? null;   // render nothing by default
    }
    return this.props.children;
  }
}

// Wrap individual blocks in the composition renderer:
<BlockErrorBoundary key={node.key}>
  <OptimizelyComponent content={node} />
</BlockErrorBoundary>

Preview mode edge cases#

The preview route has its own failure modes: expired preview tokens, deleted content, and new items with no published version. In each case the right response is a redirect, not a blank page or 500.

Preview token expiry + deleted content handling
// Preview mode edge cases - common sources of confusing errors.

// 1. Expired preview token
//    When: editor's CMS session times out while they have the preview open
//    Symptom: page renders with no content, or Graph returns 401
//    Fix: redirect to the CMS login page, or show a "Session expired" message

// src/app/preview/page.tsx
const previewToken = searchParams.get("token");
if (!previewToken) redirect("/");

const content = await getPreviewContent(url, previewToken);
if (!content) {
  // Token may be expired or the content item was deleted
  redirect(`/en${url}`);   // fall back to published version
}

// 2. Preview of a deleted item
//    getPreviewContent returns null → redirect to published URL

// 3. Preview of a new item with no published version
//    Published URL doesn't exist yet → redirect to CMS editor
//    (you can detect this by checking if the published content exists)

Key Things to Know#

  • request() throws on HTTP errors and swallows GraphQL errors. A connection failure or non-2xx status throws a typed Graph error carrying the status and query; a 200 with errors[] returns json.data and drops the errors, so partial failures read as empty content.
  • notFound() is for missing content, not Graph errors. A 404 tells search engines the URL is gone. Don't call it in a catch block - let Graph errors become 500s.
  • Wrap layout component fetches in try-catch. An unhandled error in the root layout blanks every page on the site. Return null and let the component render nothing.
  • Use hardcoded fallbacks for navigation. Navigation is critical - if Graph is down, a minimal hardcoded nav keeps the site usable.
  • Error boundaries prevent one broken block from blanking the page. Wrap blocks in the composition renderer so render errors are isolated.
  • Preview failures should redirect, not 500. Expired token → redirect to published version. Deleted content → redirect to CMS editor. Never show a blank preview page.
Source files2 files
src/lib/graphql/queries/GetNavigation.ts
import { cacheTag } from "next/cache";
import { CACHE_TAGS, cachePublishedContent, cachedQueryFailed } from "@/lib/optimizely/cacheProfile";
import { graphClient } from "@/lib/optimizely/graphClient";

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

// The cache boundary is this function, not the fetch: the SDK's request() does not
// forward next: { revalidate, tags }, but "use cache" caches the returned value, so
// cacheTag/cacheLife apply regardless of the client. The preview path deliberately
// stays outside it - a draft must never be cached or tagged, and reading a preview
// token is dynamic data that cannot cross a cache boundary.
async function fetchNavigationCached(key: string | undefined, locale: string): Promise<GetNavigationResult> {
  "use cache";
  cacheTag(CACHE_TAGS.navigation);
  cachePublishedContent();

  try {
    return await graphClient().request(
      key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
      key ? { key, locale: [locale] } : { locale: [locale] }
    );
  } catch (error) {
    return cachedQueryFailed("fetchNavigationCached", error);
  }
}

/**
 * Fetch the Navigation shared block and map its navItems into a typed NavNode
 * tree.
 *
 * By default fetches the newest Navigation block by type (the display name is
 * not used), so re-seeding with a new CMS key is transparent. Pass `key` to
 * query a specific Navigation block (e.g. for preview).
 *
 * Cached for 1 hour with a "navigation" tag — call revalidateTag("navigation")
 * from a publish webhook to bust on demand. Preview fetches bypass the cache.
 *
 * 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 data: GetNavigationResult = previewToken
      ? await graphClient().request(
          key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
          key ? { key, locale: [locale] } : { locale: [locale] },
          previewToken,
          false
        )
      : await fetchNavigationCached(key, locale);

    const root = 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 (error) {
    console.error("[getNavigation] Falling back to DEMO_NAV_DATA:", error);
    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: '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/GetSiteBanner.ts
import { cacheTag } from "next/cache";
import { CACHE_TAGS, cachePublishedContent, cachedQueryFailed } from "@/lib/optimizely/cacheProfile";
import { graphClient } from "@/lib/optimizely/graphClient";

export interface SiteBannerItem {
  message?: string | null;
  enabled?: boolean | null;
  variant?: string | null;
  linkText?: string | null;
  linkUrl?: string | null;
}

interface GetSiteBannerResult {
  SiteBanner?: {
    items?: Array<SiteBannerItem | null> | null;
  } | null;
}

// No Graph-side filter on enabled: a where clause on a field the Graph schema
// hasn't marked queryable errors the whole query, so the enabled check happens
// here instead. Newest first so a re-seeded banner wins over stale index docs.
const GET_SITE_BANNER_QUERY = /* GraphQL */ `
  query GetSiteBanner($locale: [Locales]) {
    SiteBanner(locale: $locale, orderBy: { _metadata: { lastModified: DESC } }, limit: 10) {
      items {
        message
        enabled
        variant
        linkText
        linkUrl
      }
    }
  }
`;

// The cache boundary is this function, not the fetch: the SDK's request() does
// not forward next: { revalidate, tags }, but "use cache" caches the returned
// value, so cacheTag/cacheLife apply over any client.
async function fetchSiteBanner(locale: string): Promise<GetSiteBannerResult> {
  "use cache";
  cacheTag(CACHE_TAGS.banner);
  cachePublishedContent();

  try {
    return await graphClient().request(GET_SITE_BANNER_QUERY, { locale: [locale] });
  } catch (error) {
    return cachedQueryFailed("fetchSiteBanner", error);
  }
}

export async function getSiteBanner(options: { locale?: string } = {}): Promise<SiteBannerItem | null> {
  const { locale = "en" } = options;
  try {
    const data = await fetchSiteBanner(locale);
    return data?.SiteBanner?.items?.find((item) => item?.enabled) ?? null;
  } catch (error) {
    // Only reachable for mapping errors: fetchSiteBanner already swallows Graph
    // failures inside the cache scope, because it has to (see its comment).
    console.error("[getSiteBanner] No banner rendered:", error);
    return null;
  }
}