Negotiating the Theme at the Edge with Middleware
Part of SSR Hydration & Fallback Chains. Resolving the theme at the edge gives a themed first paint without asking the origin to render two versions of every page — and it moves the caching problem somewhere it can be managed.
Prerequisites Link to this section
- A CDN or platform with programmable edge middleware and an HTML rewriting API — Cloudflare Workers, Fastly Compute, Vercel Edge Middleware or equivalent.
- A theme cookie written by the client when a visitor makes an explicit choice.
- Origin HTML that renders with a neutral or absent theme attribute, so the transform has something predictable to modify.
- The client-side resolver still in place, because the cookie is absent on a genuine first visit.
Step-by-Step Implementation Link to this section
Step 1 — Read and validate the cookie at the edge Link to this section
Intent: turn an untrusted header into one of exactly three known values.
// middleware.js
const VALID = new Set(['light', 'dark']);
function themeFromRequest(request) {
const header = request.headers.get('cookie') ?? '';
const match = /(?:^|;\s*)theme=([^;]+)/.exec(header);
const value = match ? decodeURIComponent(match[1]).trim() : '';
return VALID.has(value) ? value : null; // null means "let the client decide"
}
Why this works: the allow-list is the security boundary. A cookie value is attacker-controlled, and this value is about to be interpolated into an HTML attribute — the one place where an unvalidated string becomes markup. Returning null rather than a default keeps the “no opinion” case distinguishable from an explicit light choice, which matters for the next step.
Step 2 — Rewrite the root element in a streaming transform Link to this section
Intent: set the attribute without buffering the whole document.
export default {
async fetch(request, env) {
const theme = themeFromRequest(request);
const response = await fetch(request);
if (!theme || !response.headers.get('content-type')?.includes('text/html')) {
return response;
}
return new HTMLRewriter()
.on('html', {
element(el) {
el.setAttribute('data-theme', theme);
el.setAttribute('style', `color-scheme: ${theme}`);
},
})
.transform(response);
},
};
Why this works: a streaming rewriter operates on the response as it passes through, so time to first byte is essentially unchanged and memory use does not scale with page size. Setting color-scheme alongside the attribute matters more than it looks: it tells the browser to render form controls, scrollbars and the canvas background in the matching mode from the very first paint, which is the part visitors notice when it is missing.
Step 3 — Set Vary correctly, and keep it narrow Link to this section
Intent: tell shared caches that this response depends on the cookie, without fragmenting the cache by every cookie in the request.
const out = new Response(transformed.body, transformed);
out.headers.set('Vary', 'Cookie');
Better, where the platform supports a custom cache key, is to avoid Vary: Cookie entirely:
// Cache key includes only the resolved theme, not the whole cookie header.
const cacheKey = new Request(
`${new URL(request.url).toString()}#theme=${theme ?? 'none'}`, request);
Why this works: Vary: Cookie is correct and blunt — it keys the cache on the entire cookie header, so a session identifier or an analytics cookie fragments the cache per visitor and the hit rate collapses. A custom cache key that includes only the resolved theme produces exactly three variants per URL, which is the intended split. Where a custom key is unavailable, normalising the cookie header to just the theme cookie before the cache lookup achieves the same result.
Step 4 — Keep the client resolver as the fallback Link to this section
Intent: the cookie is absent on a first visit, and the edge has no way to know the visitor’s OS preference.
<script>
(function () {
var root = document.documentElement;
if (root.getAttribute('data-theme')) return; // the edge already decided
try {
var stored = localStorage.getItem('theme');
root.setAttribute('data-theme',
stored === 'light' || stored === 'dark' ? stored :
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
} catch (e) { root.setAttribute('data-theme', 'light'); }
})();
</script>
Why this works: the early return is what makes the two mechanisms compose rather than fight. When the edge has set the attribute, the script does nothing and there is no possibility of a mismatch; when it has not, the script is the only decision-maker and behaves exactly as it would without any edge involvement.
Step 5 — Write the cookie when the choice is made Link to this section
Intent: the edge can only act on a cookie the client remembered to set.
function persistTheme(value) {
try { localStorage.setItem('theme', value); } catch (_) {}
document.cookie =
`theme=${value}; Path=/; Max-Age=31536000; SameSite=Lax` +
(location.protocol === 'https:' ? '; Secure' : '');
}
Why this works: writing both storage and the cookie keeps the client and the edge in agreement, which is the precondition for avoiding hydration mismatches. SameSite=Lax is correct here — the cookie needs to be sent on top-level navigations, which is exactly what Lax permits, and there is no reason for a theme cookie to travel with cross-site subrequests.
Step 6 — Exclude what should not be transformed Link to this section
Intent: the rewriter should touch HTML documents and nothing else.
const url = new URL(request.url);
const skip = url.pathname.startsWith('/api/')
|| url.pathname.startsWith('/assets/')
|| request.method !== 'GET';
if (skip) return fetch(request);
Why this works: running a transform over API responses or assets costs time for no benefit and, worse, risks setting a Vary header on a response that should be cached identically for everyone. Excluding by path prefix is cruder than checking content type and considerably cheaper, and both together are what you want in production.
Verification Link to this section
Check the raw bytes first, because that is where the claim lies. curl -H "Cookie: theme=dark" against the edge URL should return HTML whose opening tag already carries the attribute; the same request without the cookie should return neutral HTML. Then check the cache behaviour: issue the same themed request twice and confirm the second is served from cache, and issue requests with different unrelated cookies and confirm they still hit the same cache entry.
Finally check the fallback. Disable JavaScript and load without a cookie: the page should render in the documented default rather than unstyled. Then enable JavaScript, still with no cookie, with the OS set to dark: the client resolver should apply dark before first paint, and no hydration warning should appear.
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| Cache hit rate collapsed after deploying | Vary: Cookie keys on the whole header |
Use a custom cache key, or normalise the cookie before the lookup |
| The attribute is set but form controls are still light | color-scheme was not set alongside it |
Set both in the same transform |
| The theme is correct on the first request and wrong on the second | A cached response from before the cookie was set is being served | Include the resolved theme in the cache key, not only in the response |
| The edge and the client disagree | The client script runs unconditionally instead of returning early | Check for an existing attribute before resolving on the client |
| The transform runs on API responses | No content-type or path exclusion | Skip non-HTML and non-GET requests before transforming |
Migration Note Link to this section
Moving theme resolution from the origin to the edge is a low-risk change because the two can coexist. Deploy the middleware with the transform disabled, confirm it passes traffic through unchanged, then enable the transform for a fraction of traffic and watch the cache hit rate and the hydration warning count together — those two numbers are what the change is trading between.
If the origin currently renders themed HTML from a cookie, keep it doing so during the transition; the edge transform is idempotent, since setting an attribute that already has the right value changes nothing. Once the edge path is confirmed, the origin can drop its cookie handling and return to serving a single neutral response, which is where the caching benefit actually arrives.
What Belongs at the Edge and What Does Not Link to this section
Theme negotiation is a good fit for edge middleware, and it is worth being explicit about why, because the same reasoning excludes a lot of things that get proposed for the same layer.
The fit is good when three conditions hold. The decision depends only on the request — a cookie, a header, a path — and needs no origin data. The transformation is small and streaming, so it adds negligible latency. And the result partitions the cache into a handful of variants rather than one per visitor. Theme negotiation satisfies all three, which is why it is nearly always a net win.
Personalisation generally fails the third condition. Rewriting a page to include a visitor’s name produces one cache entry per visitor, which is the same as not caching. Feature flags fail it too once there are more than a few, because the variant count is the product of the flags rather than their sum.
Anything requiring origin data fails the first. An edge function that fetches from a database to decide what to render has moved the origin’s latency to the edge and added a hop, which is worse than doing the work at the origin.
The useful test before adding logic at the edge: count the distinct responses the URL can now produce. If the answer is a small constant, it belongs there. If it scales with the number of visitors, it does not — regardless of how convenient the middleware makes it.
Related Link to this section
- SSR Hydration & Fallback Chains — the parent area and the fallback ordering this depends on
- Setting the Initial Theme on the Server from Cookies — the origin-side version of the same technique
- Handling SSR Hydration Mismatches in Dark Mode — what goes wrong when the two deciders disagree