SWR Middleware for CMS Locale, Preview and Logging
This guide extends SWR Stale-While-Revalidate Patterns with SWR’s middleware API, moving three cross-cutting CMS concerns out of individual hooks: adding the active locale and preview state to every key, timing and logging every CMS fetch, and keeping previous data on screen while a locale switch loads.
Without middleware, every useSWR call has to remember these concerns. One hook forgets the locale and caches English content for German readers. Another builds its preview flag differently and leaks drafts into the published cache. Logging is added to some fetchers and not others. Middleware wraps every hook under an SWRConfig provider, so the rules are written once and applied uniformly.
The Problem
A retail site on Contentful ships in six locales with a live preview for its merchandising team. Across forty hooks, the locale was added in four different ways: a query parameter, a path segment, an array key element and, in two older hooks, not at all. Preview was handled by a usePreview() hook that some components read and some did not. When merchandisers reported that the German homepage sometimes showed English promotions, the root cause turned out to be two hooks sharing a key without a locale. It took a day to find because nothing about the bug was local to the component that displayed it.
The requirement that emerged was simple: no component should be able to create a CMS cache key without the locale and preview state, and every CMS fetch should be observable in the same way.
How SWR Middleware Works
A middleware is a function that receives the next hook (useSWRNext) and returns a new hook with the same signature: (key, fetcher, config) => SWRResponse. It can change the key before calling useSWRNext, wrap the fetcher, or transform the returned data. Middleware is registered through the use option, either on a single hook or on an SWRConfig provider, where it applies to every hook beneath. Provider middleware runs before hook-level middleware, and each array runs in order.
Because middleware is itself a hook, it can call other hooks, such as a locale context or useRef. That is what makes it the right place for locale and preview state, which live in React context.
Implementation
All four middleware live in one module and are registered on the provider that wraps the app. Keys are normalized to proxy paths, so appending query parameters is safe and the proxy can read them.
// lib/swr-cms-middleware.ts
import { useContext, useEffect, useRef } from "react";
import type { Middleware, SWRHook, Key, Fetcher } from "swr";
import { LocaleContext, PreviewContext } from "@/lib/contexts";
function appendParam(key: Key, name: string, value: string): Key {
if (typeof key !== "string" || !key.startsWith("/api/cms/")) return key; // leave non-CMS keys alone
const url = new URL(key, "http://local");
url.searchParams.set(name, value);
url.searchParams.sort(); // canonical order for deduplication
return `${url.pathname}?${url.searchParams.toString()}`;
}
export const withLocale: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
const locale = useContext(LocaleContext);
return useSWRNext(appendParam(key, "locale", locale), fetcher, config);
};
export const withPreview: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
const preview = useContext(PreviewContext);
const k = preview ? appendParam(key, "preview", "1") : key;
// Preview keys refresh often and never persist; published keys keep defaults.
const cfg = preview ? { ...config, refreshInterval: 3000, revalidateOnFocus: true } : config;
return useSWRNext(k, fetcher, cfg);
};
export const withLogging: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
const timed: Fetcher<unknown> | null = fetcher
? async (...args: unknown[]) => {
const started = performance.now();
try {
const result = await (fetcher as (...a: unknown[]) => Promise<unknown>)(...args);
report({ key: String(args[0]), ms: performance.now() - started, ok: true });
return result;
} catch (err) {
report({ key: String(args[0]), ms: performance.now() - started, ok: false });
throw err;
}
}
: null;
return useSWRNext(key, timed, config);
};
// Keep showing the previous locale's data while the new locale loads.
export const withLaggyLocale: Middleware = (useSWRNext: SWRHook) => (key, fetcher, config) => {
const last = useRef<unknown>(undefined);
const swr = useSWRNext(key, fetcher, config);
useEffect(() => {
if (swr.data !== undefined) last.current = swr.data;
}, [swr.data]);
const data = swr.data === undefined ? last.current : swr.data;
return Object.assign({}, swr, { data, isLagging: swr.data === undefined && last.current !== undefined });
};
function report(entry: { key: string; ms: number; ok: boolean }): void {
const prefix = entry.key.split("?")[0];
navigator.sendBeacon?.("/api/metrics", JSON.stringify({ type: "cms-fetch", prefix, ms: Math.round(entry.ms), ok: entry.ok }));
}
Register them once, in the order they should wrap the core hook:
// app/providers.tsx
"use client";
import { SWRConfig } from "swr";
import type { ReactNode } from "react";
import { cmsFetcher } from "@/lib/cms-fetcher";
import { withLaggyLocale, withLocale, withLogging, withPreview } from "@/lib/swr-cms-middleware";
export function CmsProviders({ children }: { children: ReactNode }) {
return (
<SWRConfig value={{ fetcher: cmsFetcher, use: [withLaggyLocale, withLocale, withPreview, withLogging], dedupingInterval: 4000 }}>
{children}
</SWRConfig>
);
}
Components now write useSWR("/api/cms/pages/about") and nothing else. The locale and preview parameters are appended, the fetch is timed, and a locale switch keeps the old content visible until the new one arrives.
Configuration Reference
| Middleware | Order position | Notes |
|---|---|---|
withLaggyLocale |
first (outermost) | Must see the final data after every other layer. |
withLocale |
before preview | Locale is part of every key, preview only of some. |
withPreview |
before logging | Adjusts config for preview; logging should see preview keys. |
withLogging |
last (innermost) | Wraps the real fetcher so timings exclude React work. |
dedupingInterval |
4000 ms | Set on the provider so every key shares one rule. |
Gotchas & Edge Cases
- Non-CMS keys. Middleware on the root provider also wraps hooks for unrelated APIs. The
appendParamguard leaves keys outside/api/cms/untouched; keep such a guard in every key-transforming middleware. - Mutating keys elsewhere.
mutate("/api/cms/pages/about")in a webhook handler does not run middleware, so it targets the key without locale. Use a matcher function ((k) => typeof k === "string" && k.startsWith("/api/cms/pages/about")) or build keys with the sameappendParamhelper. - Order surprises. SWR applies provider middleware in array order, each wrapping the next, so the first entry is the outermost. Reversing
withLaggyLocaleandwithLocalewould make the laggy layer see pre-locale keys, which works but hides which locale the stale data belongs to. - Array and function keys. The helpers above handle string keys. If some hooks use array keys, extend
appendParamto add a parameter object element instead, and keep one canonical form per API. - Beacon volume. Logging every fetch on a busy page can send dozens of beacons. Sample, say 10 percent of page views, or batch entries and flush on
visibilitychange.
Verifying the Result
In React DevTools, every CMS hook’s key should now carry locale= and, in preview, preview=1. Switch locales and watch: the previous content stays visible with an isLagging flag you can use to dim it, then the new content replaces it. In your metrics backend, CMS fetch timings should arrive grouped by path prefix, which makes a slow content type visible within a day.
For the retail site in the problem statement, the migration took two days. A codemod rewrote hooks to plain proxy-path keys, the middleware went in behind a feature flag, and a week of DevTools audits and metrics confirmed that every CMS key carried a locale. The German homepage bug could no longer happen: there was no longer any code path that built a CMS key without the active locale.
Rollout Checklist
- Normalize every CMS hook to a proxy path key before adding middleware.
- Introduce
withLocalefirst and audit keys in DevTools for a week. - Add
withPreviewand remove per-component preview checks in the same change. - Update webhook and SSE handlers to use matchers or the shared key helper.
- Add logging with sampling, and build a dashboard of fetch time per prefix.
Frequently Asked Questions
Why not put the locale in the fetcher instead of the key?
Because the cache is indexed by key. A fetcher that adds the locale fetches the right content, but stores it under a key without the locale, so the next locale reads the previous one’s data. The key must describe everything that changes the response.
Does middleware affect server rendering?
SWR hooks run during server rendering of client components, and middleware runs with them, so keys match between server and client as long as the locale and preview contexts have the same values in both. Provide both contexts above the SWR provider in the root layout.
Can middleware replace a query-key factory?
For string keys and a handful of cross-cutting parameters, yes. When keys encode many content types, filters and hierarchies, a factory remains useful for building the base path, and middleware adds the global parameters on top.
Does middleware add noticeable overhead?
Each middleware is one extra function call and, for the context-based ones, one context read per hook render. That is negligible next to rendering the component itself. The logging middleware’s cost is the beacon, which is why sampling matters more than the wrapper.
How do I test middleware in isolation?
Render a tiny component that calls useSWR under an SWRConfig with only the middleware under test and a fresh cache provider, then assert on the key passed to a mocked fetcher. Each middleware is a pure hook wrapper, so the tests stay small.