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.
// 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 ↗
// 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.
// 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.
// 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>;
}// 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
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 />;
}
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;
}
}