Developer Demo

Global Settings & Singletons

How to model site-wide configuration as a singleton content item - queried once, cached for all visitors, busted by webhook on publish. The GlobalBanner at the top of this page is a live example.

The singleton pattern#

A singleton is a content type where exactly one item exists. The CMS doesn't enforce this - it's an editorial convention. The app queries with limit: 1 and handles the case where no item exists (returning a hardcoded default). Common singletons: site banner, site settings, cookie consent text, footer content.

Singleton content type + fetch function
// A singleton is a content type where editors create exactly one item.
// Query it with limit: 1 - you only ever want the first (and only) result.
//
// Common singletons:
//   SiteBanner     - the global announcement bar (message, variant, link)
//   SiteSettings   - default OG image, contact email, social links
//   CookieBanner   - cookie consent text and button labels
//   FooterContent  - footer columns, legal links, copyright text

const GET_SITE_SETTINGS_QUERY = /* GraphQL */ `
  query GetSiteSettings {
    SiteSettings(limit: 1) {
      items {
        defaultOgImage { _metadata { url { default } } }
        contactEmail
        twitterHandle
        linkedInUrl
      }
    }
  }
`;

async function fetchSiteSettings(locale: string) {
  "use cache";
  cacheTag("settings");                       // webhook busts just this tag
  cacheLife({ stale: 300, revalidate: 3600, expire: 86400 });
  try {
    return await graphClient().request(GET_SITE_SETTINGS_QUERY, { locale: [locale] });
  } catch {
    return {};   // ← INSIDE the boundary, see the note below
  }
}

export async function getSiteSettings(locale = "en") {
  try {
    const data = await fetchSiteSettings(locale);
    return data?.SiteSettings?.items?.[0] ?? null;
  } catch {
    return null;   // ← never crash the page if settings are unavailable
  }
}
// The catch goes INSIDE the cached function. A rejected promise inside
// "use cache" fails static generation outright, and a try/catch at the call
// site cannot rescue it. The trade-off: a Graph failure IS written into the
// cache entry and served for the rest of the revalidate window.

The defaultOgImage field on SiteSettings is read by generateMetadata() as the site-wide OG image fallback when a page has no image of its own. See how generateMetadata() uses it →

Three ways to query a singleton#

Query by type (most common), by _metadata.key (if you know the CMS key at deploy time), or by _metadata.url (if the item has a canonical URL). The type-with-limit approach is the most flexible - no hardcoded key needed, and it naturally returns nothing if the editor hasn't created the item yet. SDK docs ↗

Singleton query options
// Three ways to fetch a singleton from Graph:

// 1. By type with limit: 1  (most common - no key needed)
SiteBanner(limit: 1, where: { enabled: { eq: true } }) {
  items { message variant linkText linkUrl }
}

// 2. By _metadata.key  (use when you know the exact key)
_Content(where: { _metadata: { key: { eq: "site-banner-abc123" } } }, limit: 1) {
  items {
    ... on SiteBanner { message variant }
  }
}

// 3. By _metadata.url  (use when the item has a known URL)
_Content(
  where: { _metadata: { url: { default: { eq: "/global/site-banner" } } } }
  limit: 1
) {
  items {
    ... on SiteBanner { message variant }
  }
}

Cache strategy - long TTL + revalidation tag#

Global settings live in the root layout and are fetched on every page load. Use a longer ISR TTL than page content (60–300s) and assign a named tag so the publish webhook can invalidate only this query - not every cached query on the site. Always wrap the fetch in try-catch: if Graph is unavailable, the banner being absent is acceptable; the page crashing is not.

ISR + tag for targeted webhook revalidation
// Cache strategy for global settings - long TTL + revalidation tag.
//
// Singletons change rarely but are fetched on every page load (they live in the
// root layout). Use a longer TTL than page content to reduce Graph round-trips.
//
// The "banner" tag lets the publish webhook bust ONLY the banner cache
// without invalidating every other cached query on the site.

// src/lib/graphql/queries/GetSiteBanner.ts
async function fetchSiteBanner(locale: string) {
  "use cache";
  cacheTag("banner");             // webhook calls revalidateTag("banner") on publish
  cacheLife({
    stale: 300,
    revalidate: 3600,             // the site-wide CACHE_TTL
    expire: 86400,                // must be greater than revalidate
  });
  try {
    return await graphClient().request(GET_SITE_BANNER_QUERY, { locale: [locale] });
  } catch {
    return {};                    // catch INSIDE - see the error-handling demo
  }
}

export async function getSiteBanner(locale = "en"): Promise<SiteBannerItem | null> {
  try {
    const data = await fetchSiteBanner(locale);
    return data?.SiteBanner?.items?.[0] ?? null;
  } catch {
    return null;  // Graph unavailable → banner absent → page still renders
  }
}

// Because the catch is inside the boundary, an outage is cached for the
// revalidate window. If an hour of "no banner" is unacceptable for a channel
// this visible, shorten THIS query's cacheLife (e.g. revalidate: 60) rather
// than moving the catch - per-source TTLs are exactly what the tag split buys
// you.

// src/app/api/webhooks/route.ts - bust on publish:
revalidateTag("banner");
revalidateTag("navigation");
revalidatePath("/", "layout");

Feature Experimentation priority over CMS#

The GlobalBanner component at the top of this page uses a layered priority pattern: check the FX flag first, fall back to the CMS singleton. This gives the marketing team a fast path to experiment with banner messaging without waiting for a CMS publish - and gives developers a clean way to hand off banner control to editors when no experiment is running.

FX flag → CMS singleton fallback
// src/components/layout/GlobalBanner/index.tsx
//
// Pattern: Feature Experimentation flag takes priority over the CMS item.
// This lets the marketing team run banner experiments without deploying code.
// When the FX flag is disabled, the CMS-managed banner is the fallback.

export default async function GlobalBanner() {
  const user = await getOptimizelyUser();

  // 1. Check FX flag first
  const fxDecision = user.decide("banner");
  if (fxDecision.enabled) {
    const message = fxDecision.variables.title as string;
    if (!message) return null;
    void user.decide("banner", []);   // fire impression
    return <div className="bg-gradient-brand">{message}</div>;
  }

  // 2. Fall back to CMS-managed singleton
  const banner = await getSiteBanner();
  if (!banner?.enabled || !banner.message) return null;

  return <div className={variantClass}>{banner.message}</div>;
}
SiteSettings content type definition
// Define a singleton content type the same as any other -
// the "singleton" constraint is editorial convention, not enforced by the SDK.
// Editors simply agree to create only one item of this type.
//
// For stricter enforcement, set mayContainTypes on a parent folder:
export const SiteSettingsType = contentType({
  key: "SiteSettings",
  displayName: "Site Settings",
  baseType: "_component",    // or "_page" if it needs a URL
  properties: {
    defaultOgImage: { type: "contentReference", allowedTypes: ["_image"], displayName: "Default OG Image" },
    contactEmail:   { type: "string",           displayName: "Contact Email" },
    twitterHandle:  { type: "string",           displayName: "Twitter / X Handle" },
    linkedInUrl:    { type: "string",           displayName: "LinkedIn URL" },
    cookieBannerText: { type: "richText",       displayName: "Cookie Banner Text" },
  },
});

// Query - always use limit: 1 and handle null gracefully:
const settings = await getSiteSettings() ?? DEFAULT_SETTINGS;

Key Things to Know#

  • Always query singletons with limit: 1. The CMS doesn't prevent editors from creating multiple items. Your query must be defensive.
  • Always wrap singleton fetches in try-catch. Singletons live in the root layout. An unhandled error here blanks every page on the site.
  • Use a named revalidation tag for targeted cache busting. revalidateTag("banner") only busts the banner - not the navigation, page content, or other ISR caches.
  • FX flag → CMS singleton is a clean layered pattern. Experiments run via FX; when disabled, editors own the content via CMS. No code deploy needed to switch between them.
  • Return a null/default when the singleton doesn't exist. Editors might not have created it yet. getSiteBanner() returns null - the component renders nothing rather than crashing.
Source files2 files
src/components/layout/GlobalBanner/index.tsx
import { contentType } from "@optimizely/cms-sdk";
import { getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import Link from "next/link";
import { GlobalBannerClient } from "./GlobalBannerClient";

export const SiteBannerType = contentType({
  key: "SiteBanner",
  displayName: "Site Banner",
  baseType: "_component",
  // Placeable as an element in Visual Builder compositions - the banner is
  // not rendered site-wide; editors drop it onto specific pages.
  compositionBehaviors: ["elementEnabled"],
  properties: {
    message:  { type: "string",  displayName: "Message", isLocalized: true },
    // queryable: getSiteBanner (used by /demo/error-handling) filters on it
    enabled:  { type: "boolean", displayName: "Enabled", indexingType: "queryable" },
    variant:  { type: "string",  displayName: "Variant (info / warning / success / brand)" },
    linkText: { type: "string",  displayName: "Link Text", isLocalized: true },
    linkUrl:  { type: "string",  displayName: "Link URL" },
  },
});

const VARIANT_CLASSES: Record<string, string> = {
  brand:   "bg-gradient-brand text-on-brand",
  info:    "bg-brand/10 text-brand",
  warning: "bg-error/10 text-error",
  success: "bg-brand-fill/20 text-brand",
};

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

type SiteBannerBlockProps = SiteBannerData & { content?: SiteBannerData };

// Renders the SiteBanner block inside page compositions (and as the CMS edit
// preview). FX-aware: when the "banner" flag serves a banner1-4 variation for
// the visitor, that FX variant renders in place of the CMS content; otherwise
// the CMS-configured strip shows. The Enabled toggle hides the placement
// entirely (FX included) without removing the block.
export function SiteBannerBlock(props: SiteBannerBlockProps) {
  const data = props.content ?? props;
  const { pa } = getPreviewUtils(data as Parameters<typeof getPreviewUtils>[0]);

  if (data.enabled === false || !data.message) return null;

  const variantClass = VARIANT_CLASSES[data.variant ?? "info"] ?? VARIANT_CLASSES.info;

  const cmsStrip = (
    <div data-component="SiteBannerBlock" className={`h-9 flex items-center justify-center text-sm font-medium gap-2 px-4 ${variantClass}`}>
      <span {...pa("message")}>{data.message}</span>
      {data.linkText && data.linkUrl && (
        <Link
          href={data.linkUrl}
          className="underline underline-offset-2 font-semibold hover:opacity-80 transition-opacity"
        >
          <span {...pa("linkText")}>{data.linkText}</span>
        </Link>
      )}
    </div>
  );

  return <GlobalBannerClient fallback={cmsStrip} />;
}

// Site-wide chrome slot: only the FX banner experiment renders here. The CMS
// SiteBanner block is placed on specific pages via Visual Builder instead
// (rendered by SiteBannerBlock through the component registry).
export default function GlobalBanner() {
  return <GlobalBannerClient />;
}
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;
  }
}