Where should we persist the theme preference in an SSR app?#
Implementing dark mode often seems like a trivial feature. In practice, it turns out to be surprisingly tricky in server-rendered applications.
In a purely client-side app, persisting the theme preference is straightforward: store the value somewhere in the browser like localStorage and read it again when the app loads. However, doing the same in an SSR app comes with a catch.
Storing it in localStorage#
Naturally, the first step is to reach for the same approach that works in a client-side app: persist the theme in localStorage.
const STORAGE_KEY = "theme";
type Theme = "light" | "dark";
const DEFAULT_THEME: Theme = "light";
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState(
() => (localStorage.getItem(STORAGE_KEY) as Theme) ?? DEFAULT_THEME
);
const toggleTheme = () => {
const next: Theme = theme === "dark" ? "light" : "dark";
localStorage.setItem(STORAGE_KEY, next);
setTheme(next);
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}There's a problem though — the server has no localStorage, so this throws an error immediately.
at ThemeProvider (theme.tsx:7:30)
To fix it, we need to guard the read from localStorage with typeof window !== "undefined". This way, the server falls back to the default theme and only the browser reads from localStorage:
const [theme, setTheme] = useState<Theme>(- () => (localStorage.getItem(STORAGE_KEY) as Theme) ?? DEFAULT_THEME+ () =>+ typeof window !== "undefined"+ ? (localStorage.getItem(STORAGE_KEY) as Theme) ?? DEFAULT_THEME+ : DEFAULT_THEME );Try it: switch to dark mode, then refresh the page.
If you refreshed, you likely saw the page flash light for a moment before switching to dark. That happens because the server sends the initial HTML before any JavaScript runs — it has no access to localStorage, so it always renders the default theme. By the time the client loads and reads your stored preference, the user has already seen the wrong one.
To fix this, the server needs to know the theme before it sends the initial HTML. Since the server can't read localStorage, we need to store it somewhere it can read — cookies.
Storing it in cookies#
Cookies are sent with every HTTP request, so the server can read them before rendering anything. Here's how we can do that in TanStack Start:
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { type ReactNode, createContext, useContext } from "react";
import { z } from "zod";
const storageKey = "theme";
const themeSchema = z.enum(["light", "dark"]);
export type Theme = z.infer<typeof themeSchema>;
export const getThemeServerFn = createServerFn()
.handler((): Theme => {
const raw = getCookie(storageKey) ?? "dark";
const result = themeSchema.safeParse(raw);
return result.success ? result.data : "dark";
});
export const setThemeServerFn = createServerFn()
.inputValidator(themeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const { theme: serverTheme } = useRouteContext({ from: "__root__" });
const router = useRouter();
const toggleTheme = async () => {
const next: Theme = serverTheme === "dark" ? "light" : "dark";
await setThemeServerFn({ data: next });
await router.invalidate();
};
return (
<ThemeContext.Provider value={{ theme: serverTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}export const Route = createRootRoute({
beforeLoad: async () => ({
theme: await getThemeServerFn(),
}),
// ...
});The server reads the cookie, sets the correct class on <html>, and the first paint matches the user's preference. No flash. (Later we'll add a client cache so client-side navigations don't refetch the theme; this is the minimal version.)
Try it: toggle the theme and refresh the page. The correct theme appears on first load with no flash.
Why is the toggle slow?#
If you tried toggling in the demo above, you likely noticed that it's not instant. There's a delay between when you click the toggle and when the theme changes. That's because every toggle has to wait for two server round-trips before the UI updates.
setThemeServerFnwrites the new value to the cookie on the server.router.invalidate()re-runsbeforeLoad, which callsgetThemeServerFn()to read the updated cookie.
To make the toggle feel instant, we can optimistically apply the new theme before the server responds.
export function ThemeProvider({ children }: { children: ReactNode }) { const { theme: serverTheme } = useRouteContext({ from: "__root__" }); const router = useRouter();+ const [theme, setOptimisticTheme] = useOptimistic(serverTheme);+ const requestRef = useRef(0); - const toggleTheme = async () => {- const next: Theme = serverTheme === "dark" ? "light" : "dark";- await setThemeServerFn({ data: next });- await router.invalidate();- };+ const toggleTheme = () => {+ const next: Theme = theme === "dark" ? "light" : "dark";+ const id = ++requestRef.current;+ startTransition(async () => {+ setOptimisticTheme(next);+ await setThemeServerFn({ data: next });+ if (id === requestRef.current) await router.invalidate();+ });+ }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> );}setOptimisticTheme(next) updates the UI immediately. The server write and invalidation happen in the background. If the server call fails, the optimistic value rolls back to serverTheme automatically. The requestRef guard ensures we only call router.invalidate() if this request is still the latest — so rapid toggles don't let an older response overwrite the theme. The toggle is instant now.
Try it: the toggle should feel instant.
But there's another problem. Try navigating.
Why is navigation slow?#
In the demo above, the toggle is instant. But click between Home and About — every navigation takes about a second. The app isn't re-rendering on the server — it's waiting for a single string.
Here's why. In TanStack Start, beforeLoad runs on the server for the initial request and on the client for every subsequent navigation. When it runs on the client, getThemeServerFn() is still a server function — it makes a network request back to the server. The route can't finish loading until that request resolves. Every link click, every back button, every forward navigation pays this cost.
Think of it like hydration. On the initial page load, the server sends the theme with the HTML — the client needs that to render correctly. But after hydration, the client already knows the theme. It's right there in memory. There's no reason to ask the server for it again. After the first load, this should behave like a SPA.
To make navigation instant, cache the theme on the client and read from it in beforeLoad when we're in the browser. Store the theme in a module-level variable; have beforeLoad check typeof window: on the server, call the server function; on the client, return the cached value. No network request.
const themeSchema = z.enum(["light", "dark"]);export type Theme = z.infer<typeof themeSchema>; + // ── Client cache ──────────────────────────────────────+ let clientThemeCache: Theme = "dark";+ export function getThemeForClientNav(): Theme {+ return clientThemeCache;+ }+ export function setThemeForClientNav(theme: Theme): void {+ clientThemeCache = theme;+ }+// ── Server functions ──────────────────────────────────export const getThemeServerFn = createServerFn() .handler((): Theme => { ... }); export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { const { theme: serverTheme } = useRouteContext({ from: "__root__" }); const router = useRouter(); const [theme, setOptimisticTheme] = useOptimistic(serverTheme); const requestRef = useRef(0); + useEffect(() => {+ setThemeForClientNav(serverTheme);+ }, [serverTheme]); const toggleTheme = () => { const next: Theme = theme === "dark" ? "light" : "dark"; const id = ++requestRef.current;+ setThemeForClientNav(next); startTransition(async () => { setOptimisticTheme(next); await setThemeServerFn({ data: next }); if (id === requestRef.current) await router.invalidate(); }); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> );}The root route's beforeLoad calls the server on the initial request and the cache on the client:
beforeLoad: async () => {
if (typeof window === "undefined") {
return { theme: await getThemeServerFn() };
}
return { theme: getThemeForClientNav() };
},The demo below uses the client cache: on the client, beforeLoad calls getThemeForClientNav() instead of the server, so navigation is instant. Click between Home and About and compare with the previous demo (cookie-optimistic).
Full implementation#
Two files. The theme module with server functions, client cache, and React context:
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import {
type ReactNode,
createContext,
startTransition,
useContext,
useEffect,
useOptimistic,
useRef,
} from "react";
import { z } from "zod";
const storageKey = "theme";
const themeSchema = z.enum(["light", "dark"]);
export type Theme = z.infer<typeof themeSchema>;
// ── Client cache ──────────────────────────────────────────
let clientThemeCache: Theme = "dark";
export function getThemeForClientNav(): Theme {
return clientThemeCache;
}
export function setThemeForClientNav(theme: Theme): void {
clientThemeCache = theme;
}
// ── Server functions ──────────────────────────────────────
export const getThemeServerFn = createServerFn()
.handler((): Theme => {
const raw = getCookie(storageKey) ?? "dark";
const result = themeSchema.safeParse(raw);
return result.success ? result.data : "dark";
});
export const setThemeServerFn = createServerFn()
.inputValidator(themeSchema)
.handler(async ({ data }) => {
setCookie(storageKey, data);
});
// ── Provider ──────────────────────────────────────────────
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
const { theme: serverTheme } = useRouteContext({ from: "__root__" });
const router = useRouter();
const [theme, setOptimisticTheme] = useOptimistic(serverTheme);
const requestRef = useRef(0);
useEffect(() => {
setThemeForClientNav(serverTheme);
}, [serverTheme]);
const toggleTheme = () => {
const next: Theme = theme === "dark" ? "light" : "dark";
const id = ++requestRef.current;
setThemeForClientNav(next);
startTransition(async () => {
setOptimisticTheme(next);
await setThemeServerFn({ data: next });
if (id === requestRef.current) {
await router.invalidate();
}
});
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within ThemeProvider");
}
return ctx;
}And the root route:
import {
getThemeForClientNav,
getThemeServerFn,
ThemeProvider,
useTheme,
} from "../lib/theme";
export const Route = createRootRoute({
beforeLoad: async () => {
if (typeof window === "undefined") {
return { theme: await getThemeServerFn() };
}
return { theme: getThemeForClientNav() };
},
// ...
});References#
- TanStack Start — Execution model — how
beforeLoadruns on server vs client - TanStack Start — Selective SSR — controlling what runs on the server per route
- React — useOptimistic — optimistically update UI before a server response
Found an issue? Open a PR or send me an email.