What does a client without JavaScript actually receive?
A theme chosen inside an effect does not exist in the HTML you serve, so every client that does not execute your bundle renders the fallback palette, and the crawlers building retrieval indexes read that first response body rather than the DOM after hydration. The correct default theme has to be whatever the server wrote into the document, because for those clients it is the only theme that ever exists.
I have shipped the standard version of this bug more than once: a client component owns the theme, an effect reads storage, and the root element's attribute is mutated after the bundle parses. With a warm cache it looks right, and in review it looks right, because the reviewer is a human in a browser with scripting enabled. Turn scripting off and the same URL renders whichever palette I wrote first, chosen at build time rather than by the reader's operating system.
A theme is not applied by JavaScript; it is decided by the server, and every client that cannot run your script — including most AI crawlers — renders whatever your HTML said the theme was.
Everything below follows from taking that sentence literally instead of treating a pre-paint script as the fix. That script removes the flash for clients that run it and does nothing for the clients that do not, which are the ones you cannot correct afterwards.
What those clients lose is larger than a colour swap. Three theme-bound surfaces are resolved by the user agent from server-rendered information, and none of them recovers on its own:
color-schemeon the root element decides how the browser paints scrollbars, native select popups, and the default canvas, so a post-hydration change means the first frame is painted against the wrong value.- The theme-color meta tag colours the browser chrome, leaving the address bar wrong until the bundle lands.
- A theme-specific asset, such as a diagram on a transparent background, must be selected by CSS or an attribute rather than by swapping a source at runtime, because with scripting off no swap happens.
The honest test is not a browser: request the document with nothing attached and check whether the theme is already in the bytes.
Why is the pre-paint script not enough?
The conventional fix is a small synchronous script in the document head that reads stored preference and sets the attribute before first paint. It is a real fix for flash, and I still use it in one case below. It does not answer the question a no-JS client asks, because it is still JavaScript.
It also carries three costs that are easy to underestimate. The first is policy: an inline script needs unsafe-inline, a nonce, or a hash under a strict script-src. I have watched a CSP rollout block one silently — no error anyone read, no failing test, every returning visitor dropped to the default theme because the single line holding their preference never executed. A defect that depends on a security header survives review, because the header and the asset live in different files.
The second is duplication. The script reimplements theme resolution inside an untyped string, while the application already has a typed function doing the same work. A cookie name changed in one place and not the other changes the theme after paint for exactly the users who set something unusual.
The third is that it runs on every navigation to compensate for a decision made on the wrong side of the network. The minified version I ship is 138 bytes, which is not a performance argument by itself.
How does the server learn the preference without JavaScript?
Storage is not readable by the server. Cookies are. That asymmetry is the whole design: keep the preference in a cookie, read it while rendering, and write the answer into the markup. The default then becomes correct by construction rather than by timing.
import { cookies } from 'next/headers';
export type ThemePreference = 'system' | 'light' | 'dark';
const COOKIE = 'theme';
const VALID = new Set<string>(['system', 'light', 'dark']);
function isThemePreference(value: string): value is ThemePreference {
return VALID.has(value);
}
/** One validator, shared by the render path and the write path. */
export function parsePreference(raw: string | undefined): ThemePreference {
return raw !== undefined && isThemePreference(raw) ? raw : 'system';
}
/**
* The value the server writes onto the root element as data-theme. `system` is
* rendered verbatim and finished by the media query, with no client code.
*/
export async function resolveThemePreference(): Promise<ThemePreference> {
const store = await cookies();
return parsePreference(store.get(COOKIE)?.value);
}
Two details matter. In Next.js 16 cookies() is asynchronous, so the resolver is async and the layout awaiting it renders per request; that is the cost, and the tradeoffs section is about when to pay it. And an unrecognised cookie resolves to system rather than throwing, because a stale or hand-edited cookie should degrade to a sensible theme instead of a 500.
The layout passes that value to the root element's data-theme attribute. The attribute costs twenty bytes of HTML and is the only theme information a non-executing client will ever see.
Which cascade order makes the OS preference the fallback?
A server-rendered attribute is only useful if the stylesheet treats it as authoritative over the media query, which means a specific source order and correct specificity arithmetic.
/* The default is resolved by the CSS engine, which every client has. */
:root {
color-scheme: light;
--surface: oklch(0.99 0.002 250);
--ink: oklch(0.22 0.012 250);
--line: oklch(0.90 0.006 250);
}
@media (prefers-color-scheme: dark) {
/* The :not() clause keeps an explicit light choice from being overruled. */
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface: oklch(0.17 0.008 250);
--ink: oklch(0.94 0.004 250);
--line: oklch(0.30 0.010 250);
}
}
/* Equal specificity to the media rule above, so source order decides. */
:root[data-theme="dark"] {
color-scheme: dark;
--surface: oklch(0.17 0.008 250);
--ink: oklch(0.94 0.004 250);
--line: oklch(0.30 0.010 250);
}
The three cases resolve as follows. With data-theme="system" the media query decides, so a reader on a dark operating system gets dark before a byte of script runs. With data-theme="light" the :not() clause disables the media rule and the base palette stands, which is what lets an explicit light choice survive on a dark machine. With data-theme="dark" the last rule applies; it has the same specificity as the media rule, so source order wins and it has to stay below it.
color-scheme earns its place in all three blocks. A dark palette declared as custom properties without color-scheme: dark still leaves scrollbars, form controls and the overscroll canvas light, putting a white band at the end of a dark page. That band is not a flash, and no screenshot test catches it.
The duplicated palette above is the part I would delete deliberately. Two hand-maintained colour blocks diverge within a quarter, and a server-rendered theme built on drifted palettes is a correct mechanism wrapped around wrong values. Both blocks should be emitted from the same source, which is the arrangement described in generating both palettes from one typed token source.
How do you keep the toggle itself working without JavaScript?
If the theme is server-rendered, the control that changes it should be a request rather than a callback. A form POST to a route handler works with scripting disabled and keeps the write path validated by the same function the render path uses.
import { NextResponse, type NextRequest } from 'next/server';
import { parsePreference } from '@/lib/theme';
/** A form POST, so the toggle works with scripting disabled. */
export async function POST(request: NextRequest): Promise<NextResponse> {
const form = await request.formData();
const preference = parsePreference(String(form.get('theme') ?? ''));
// Bounce back to the page they were reading, but only within this origin.
const referer = request.headers.get('referer');
const back =
referer?.startsWith(request.nextUrl.origin) === true
? referer
: `${request.nextUrl.origin}/`;
// 303 turns the POST into a GET, so a refresh does not resubmit the choice.
const response = NextResponse.redirect(back, 303);
response.cookies.set('theme', preference, {
path: '/',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
// Readable by the fallback below; a display preference, not a credential.
httpOnly: false,
});
return response;
}
/** Only an explicit choice needs restoring before first paint. */
export const PRE_PAINT =
"(function(){try{var m=/(?:^|; )theme=(light|dark)/.exec(document.cookie);if(m){document.documentElement.dataset.theme=m[1]}}catch(e){}})()";
The referer bounce matters more than it looks: I send the reader back to the referring page when it shares the request origin and to the site root otherwise, which prevents a themed open redirect. The value is allowlisted, so nothing else in the handler is worth attacking.
The same button can be progressively enhanced. When scripting is available, intercept the submit and POST with fetch to skip the navigation; when it is not, the form submits and the reader gets the same theme one round trip later. The enhancement optimises a path that already works, which is the opposite of the usual arrangement.
That last constant is where the inline script returns, for pages served from a shared cache that cannot carry a per-user attribute. It restores only an explicit choice and leaves system to the media query, so its absence is harmless instead of wrong: a client that never runs it keeps the server's default, which is the operating system preference and correct for the majority of visitors who never opened the toggle.
Which of these approaches should you pick?
The decision is about what the first response body states, since that is what a non-executing client is stuck with.
| Approach | Theme in the first HTML response | Scripting disabled | Cold-load flash | Shared-cache friendly |
|---|---|---|---|---|
| Effect sets the attribute | none | fallback palette, override lost | yes | yes |
| Inline pre-paint script plus local storage | none | fallback palette, override lost | no | yes |
| Media query only | operating system default | correct for that system | no | yes |
| Cookie read at render, media query default | exact stored preference | correct, including the override | no | no, unless HTML is per request |
| Cookie at render plus pre-paint fallback | exact stored preference | correct, but override lost | no | yes |
Read the fourth and fifth rows together: same mechanism, one addition. The fifth is what I ship where dynamic and cached pages are mixed. The server renders the exact preference where it can, and the 138-byte script covers pages arriving from a shared cache, where a no-JS client gets the operating system default rather than its own explicit choice. That loss is real and named rather than smoothed over.
When is server-rendering the theme the wrong approach?
First, when the HTML cannot be per-user. A static export on a CDN has one document per URL, and folding a cookie into rendering means either an uncacheable document or Vary: Cookie on every page. Two themes are two cache variants per URL, and every extra cookie you fold into rendering multiplies that; for a large anonymous audience the hit rate you lose is a real bill paid for a colour preference. If your pages are exported statically, keep the media query as the default and the pre-paint script as the override.
Second, the cookie is per device, not per account. A reader who chooses dark on a laptop gets the operating system default on a phone. The fix is persisting the preference against the user record once someone is signed in and merging it at render time, with the cookie as the pre-authentication default. That is a data model change, and for most sites it is not worth making.
Third, the two-valued assumption. Light and dark both fit in a media query and an attribute; with eleven accent themes the attribute becomes a per-user token with a cache cost per value, and the honest shape there is a correct server default plus a client-side variable swap for the accent.
Fourth, the cases where the preference stops applying. Under Windows High Contrast, and in any engine reporting forced-colors: active, your palette is replaced, so a design that assumes the theme always wins looks broken. Print is the same case: a print stylesheet should force the light tokens, because a dark palette flattened into a PDF attachment is unreadable.
How do I confirm the server is rendering the theme?
Four requests, no browser, no devtools, run against the deployed URL, because this failure is invisible in a build log.
# 1. What a client with scripting disabled receives: the server's default.
curl -s https://willchan.me/ | grep -o 'data-theme="[a-z]*"'
# 2. The stored preference is in the markup before any script can run.
curl -s -H 'Cookie: theme=dark' https://willchan.me/ | grep -o 'data-theme="[a-z]*"'
# 3. An unknown value falls back to system rather than reaching the markup.
curl -s -H 'Cookie: theme=neon' https://willchan.me/ | grep -o 'data-theme="[a-z]*"'
# 4. Nothing on this path rewrites the theme client-side. Expect 0.
curl -s -H 'Cookie: theme=dark' https://willchan.me/ | grep -c 'documentElement.dataset.theme'
The first request returning data-theme="system" is the assertion that matters, because it is what a crawler and a reader with scripting disabled both receive. The third returning system rather than an empty attribute or a 500 proves the validator runs on the write path. The fourth confirms the theme is genuinely server-side rather than server-side and then corrected; note that grep -c exits non-zero when the count is zero, so write it as || true in CI or the step fails precisely when it passes. If your CDN does not vary on the cookie, the first command reports the cached variant instead, which is the test correctly telling you the HTML is shared.
What should change in your layout first?
Pick the theme resolver, not the palette. Move the preference into a cookie, read it in the layout, render it as an attribute on the root element, then delete the effect that set it after paint, which moves the responsibility from a script that may not run to a response that always does. Convert the toggle to a form POST so the control keeps working without scripting, and check the endpoint returns system for a garbage cookie before you check that dark mode looks good. Then request your own page with curl and no cookie, and read the attribute: that is what most of the machines reading your site will see, and the only version of your theme you can be sure about.
Keep reading
- Backdrop Blur Belongs in the Chrome, Never in the Scroll Container2026-01-299 minDesign Engineering
- A Scroll Animation Must Hide Content After the Observer Attaches, Never in the Markup2026-01-269 minDesign Engineering
- Design Engineering: Closing the Figma-to-Production Gap Without a Handoff2026-03-047 minDesign Engineering
- Design Tokens as Typed Code: If the Build Does Not Consume It, It Is Documentation2026-02-287 minDesign Engineering