Every Sanity settings singleton looked identical until it did not. Cart labels, checkout copy, footer links, navigation tree, announcement bar, error page: each module had fetch, map, 'use cache', soft-fail fallback. Copy-paste with different query strings.
Drift was inevitable. One loader passed publishedFetchOptions, another forgot. One mapped null to empty strings, another threw. One used cacheLife('hours'), another hard-coded seconds. Fixing cache semantics meant editing six files and hoping grep caught the seventh.
createSanitySettingsLoader({ query, map, cacheLife, cacheDirective }) returns loadSettings. The factory owns shared Sanity fetch (dynamic vs metadata mode) and mapping. createSettingsWithFallback(loadCached, emptyView) wraps tryCatch outside the cache boundary so a failed fetch returns fallback copy without poisoning the cache entry.
Cached loaders still export as top-level 'use cache' functions in each service module. Next.js must statically analyze the cache boundary. You cannot hide 'use cache' inside a generic factory closure and expect the compiler to see it. The factory builds the inner loader; the service module wraps it in the directive the framework recognizes.
export function createSanitySettingsLoader<QueryString extends string, T>(config) {
async function loadSettings(locale, fetchOptions = publishedFetchOptions): Promise<T> {
const { data } = await fetchSanitySettingsData(config.fetchMode ?? 'dynamic', config.query, fetchOptions);
return config.map(asSanityQueryData(data ?? null), locale);
}
return { cacheDirective: config.cacheDirective, cacheLife: config.cacheLife, loadSettings };
}
export function createSettingsWithFallback(loadCachedSettings, fallback) {
return async function loadSettingsWithFallback(locale, fetchOptions) {
const result = await tryCatch(loadCachedSettings(locale, fetchOptions));
return result.data ?? fallback();
};
}New singleton? Wire query and map, export one cached loader, move on. The boring glue stops multiplying.
Happy coding! Sander