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

export async function getSiteSettings() {
  try {
    const res = await graphqlFetch(GET_SITE_SETTINGS_QUERY, {}, {
      next: { revalidate: 300, tags: ["site-settings"] },
    });
    return res.data?.SiteSettings?.items?.[0] ?? null;
  } catch {
    return null;   // ← never crash the page if settings are unavailable
  }
}

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
export async function getSiteBanner(): Promise<SiteBannerItem | null> {
  try {
    const result = await graphqlFetch(GET_SITE_BANNER_QUERY, {}, {
      next: {
        revalidate: 60,           // re-fetch at most once per minute
        tags: ["banner"],         // webhook calls revalidateTag("banner") on publish
      },
    });
    return result.data?.SiteBanner?.items?.[0] ?? null;
  } catch {
    return null;  // Graph unavailable → banner absent → page still renders
  }
}

// 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-amber-50 text-amber-800 dark:bg-amber-900/20 dark:text-amber-300",
  success: "bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-300",
};

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