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.
Two error cases in graphqlFetch#
graphqlFetch behaves differently depending on where the error occurs. An HTTP error (non-2xx status) throws an unhandled Error. A GraphQL error (200 OK with errors[] in the body) logs to console and returns the response - it does not throw. Callers must handle both cases explicitly. SDK docs ↗
// src/lib/optimizely/client.ts - two distinct error cases
// Case 1: HTTP error (non-2xx response) → graphqlFetch THROWS
// When: network issue, Graph is down, invalid API key, rate limit
// Result: unhandled Error propagates up the call stack
// Caller responsibility: wrap in try-catch if absence is acceptable
const response = await fetch(GRAPH_ENDPOINT, fetchOptions);
if (!response.ok) {
throw new Error(
`GraphQL request failed: ${response.status} ${response.statusText}`
);
}
// Case 2: GraphQL errors (200 OK but errors[] in body) → graphqlFetch RETURNS
// When: invalid query, unknown field, permission error on a field
// Result: { data: null, errors: [{message: "..."}] } - does not throw
// Caller responsibility: check result.errors if needed, handle data: null
const result: GraphQLResponse<T> = await response.json();
if (result.errors?.length) {
console.error("[GraphQL Errors]", result.errors);
// still returns - data may be partially populated
}
return result;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 - 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 graphqlFetch
if (!page) {
const res = await graphqlFetch(KEY_QUERY, { url });
page = res.data?._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 404Per-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.
// 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
export async function getSiteBanner(): Promise<SiteBannerItem | null> {
try {
const result = await graphqlFetch(GET_SITE_BANNER_QUERY, {}, {
next: { revalidate: 60, tags: ["banner"] },
});
return result.data?.SiteBanner?.items?.[0] ?? null; // null if empty
} catch {
return null; // Graph down → no banner → page still renders normally
}
}
// 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 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" },
],
};
export async function getNavigation() {
try {
const res = await graphqlFetch(GET_NAV_QUERY, {}, {
next: { revalidate: 300, tags: ["navigation"] },
});
return res.data?.Navigation?.items?.[0] ?? FALLBACK_NAV;
} catch {
return FALLBACK_NAV; // Graph down → minimal 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.
// 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 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#
- →graphqlFetch throws on HTTP errors, returns on GraphQL errors. HTTP errors (Graph down, rate limit) throw; GraphQL errors (bad field, permission) return
{ data: null, errors: [...] }. Handle both paths. - →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
const GRAPH_ENDPOINT =
process.env.OPTIMIZELY_GRAPH_GATEWAY ?? "https://cg.optimizely.com/content/v2";
const SINGLE_KEY = process.env.OPTIMIZELY_GRAPH_SINGLE_KEY ?? "";
// Default time-based ISR window (seconds) for published content. Freshness is
// driven by the publish webhook (revalidatePath/revalidateTag); this 1-hour TTL
// is the fallback ceiling. Keep the `export const revalidate` in the catch-all
// page route in sync with this value.
export const CACHE_TTL = 3600;
export interface GraphQLRequestOptions {
/** Bearer token from CMS iframe for draft/preview content */
previewToken?: string;
/** Next.js fetch revalidation config */
next?: { revalidate?: number; tags?: string[] };
/** Override fetch cache behavior */
cache?: RequestCache;
}
export interface GraphQLResponse<T> {
data: T | null;
errors?: Array<{ message: string; locations?: unknown; path?: unknown }>;
}
export async function graphqlFetch<T = unknown>(
query: string,
variables?: Record<string, unknown>,
options: GraphQLRequestOptions = {}
): Promise<GraphQLResponse<T>> {
const { previewToken, next, cache } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (previewToken) {
headers["Authorization"] = `Bearer ${previewToken}`;
} else {
headers["Authorization"] = `epi-single ${SINGLE_KEY}`;
}
const fetchOptions: RequestInit & { next?: { revalidate?: number; tags?: string[] } } = {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
};
if (cache) {
fetchOptions.cache = cache;
} else if (next) {
fetchOptions.next = next;
} else if (!previewToken) {
fetchOptions.next = { revalidate: CACHE_TTL };
} else {
fetchOptions.cache = "no-store";
}
const response = await fetch(GRAPH_ENDPOINT, fetchOptions);
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new Error(
`GraphQL request failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`
);
}
const result: GraphQLResponse<T> = await response.json();
if (result.errors?.length) {
console.error("[GraphQL Errors]", JSON.stringify(result.errors, null, 2));
}
return result;
}
import { graphqlFetch, CACHE_TTL } from "@/lib/optimizely/client";
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
}
}
}
`;
export async function getSiteBanner(options: { locale?: string } = {}): Promise<SiteBannerItem | null> {
const { locale = "en" } = options;
try {
const result = await graphqlFetch<GetSiteBannerResult>(
GET_SITE_BANNER_QUERY,
{ locale: [locale] },
{ next: { revalidate: CACHE_TTL, tags: ["banner"] } }
);
return result.data?.SiteBanner?.items?.find((item) => item?.enabled) ?? null;
} catch {
return null;
}
}