Tools: How to add theming to an SSR app (TanStack Start)

Tools: How to add theming to an SSR app (TanStack Start)

Source: Dev.to

Where should we persist the theme preference in an SSR app? ## Storing it in localStorage ## Storing it in cookies ## Why is the toggle slow? ## Why is navigation slow? ## Full implementation ## References 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. Naturally, the first step is to reach for the same approach that works in a client-side app: persist the theme in localStorage. There's a problem though — the server has no localStorage, so this throws an error immediately. ReferenceError: localStorage is not defined 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: Try it: switch to dark mode, then refresh the page. ▶ Try the interactive demo 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. 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: 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. ▶ Try the interactive demo 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. To make the toggle feel instant, we can optimistically apply the new theme before the server responds. 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. ▶ Try the interactive demo But there's another problem. Try navigating. 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. The root route's beforeLoad calls the server on the initial request and the cache on the client: 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). ▶ Try the interactive demo Two files. The theme module with server functions, client cache, and React context: src/routes/__root.tsx Originally published on ishchhabra.com. Follow me there for more. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: 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> ); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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 ); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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 ); COMMAND_BLOCK: 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 ); COMMAND_BLOCK: 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; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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; } COMMAND_BLOCK: 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; } COMMAND_BLOCK: export const Route = createRootRoute({ beforeLoad: async () => ({ theme: await getThemeServerFn(), }), // ... }); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: export const Route = createRootRoute({ beforeLoad: async () => ({ theme: await getThemeServerFn(), }), // ... }); COMMAND_BLOCK: export const Route = createRootRoute({ beforeLoad: async () => ({ theme: await getThemeServerFn(), }), // ... }); COMMAND_BLOCK: 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> ); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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> ); } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: 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> ); } COMMAND_BLOCK: beforeLoad: async () => { if (typeof window === "undefined") { return { theme: await getThemeServerFn() }; } return { theme: getThemeForClientNav() }; }, Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: beforeLoad: async () => { if (typeof window === "undefined") { return { theme: await getThemeServerFn() }; } return { theme: getThemeForClientNav() }; }, COMMAND_BLOCK: beforeLoad: async () => { if (typeof window === "undefined") { return { theme: await getThemeServerFn() }; } return { theme: getThemeForClientNav() }; }, COMMAND_BLOCK: 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; } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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; } COMMAND_BLOCK: 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; } COMMAND_BLOCK: 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() }; }, // ... }); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: 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() }; }, // ... }); COMMAND_BLOCK: 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() }; }, // ... }); - setThemeServerFn writes the new value to the cookie on the server. - router.invalidate() re-runs beforeLoad, which calls getThemeServerFn() to read the updated cookie. - TanStack Start — Execution model — how beforeLoad runs 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