Sanity settings loader factory

Six Sanity singletons shared the same fetch-map-cache boilerplate. A factory plus fallback wrapper keeps use cache at the top level without copy-paste drift.

Sander Korf2 min read
sanitynextjstypescript

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