Implement Dark Mode in TanStack Start
Sun Apr 06 2025
Recently, I started rewriting my project using the TanStack Start framework. One of the features I wanted to implement early on was a light/dark mode toggle. My initial approach used localStorage
to store the user's preference, but I quickly ran into a common issue — a flash of the incorrect theme on the initial page load.
I didn’t want that flickering experience, especially when aiming for a polished UI. So I switched gears and moved the theme handling to the server side by storing the preference in a cookie instead. This way, the correct theme gets applied during SSR itself — no more flickering.
Let’s see how the implementation goes.
Step 1 — Creating getThemeServerFn
and setThemeServerFn
I started by creating two serverFn
utilities:
getThemeServerFn
retrieves the current theme from the cookie (if present) or falls back to a default.setThemeServerFn
updates the cookie with the new theme value.
This ensures that the theme is handled on the server, preventing flickering on the initial load and keeping the experience consistent across routes and refreshes.
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import * as z from "zod";
const postThemeValidator = z.union([z.literal("light"), z.literal("dark")]);
export type T = z.infer<typeof postThemeValidator>;
const storageKey = "_preferred-theme";
export const getThemeServerFn = createServerFn().handler(
async () => (getCookie(storageKey) || "light") as T,
);
export const setThemeServerFn = createServerFn({ method: "POST" })
.inputValidator(postThemeValidator)
.handler(async ({ data }) => setCookie(storageKey, data));
Step 2 — Using the theme during SSR
Now let’s use this getThemeServerFn
in our __root.tsx
file to determine the theme during SSR.
In TanStack, while defining a route, we can pass a loader
function that runs on the server before rendering.
This gives us the perfect place to fetch the theme ahead of time.
We’ll apply this theme to the root <html>
element during server rendering, ensuring the correct mode is visible from the very first paint.
Our goal is simple: if the user prefers a dark theme, we should add a
dark
class to the root HTML tag before hydration.
import { createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
import { ThemeProvider } from "@/components/theme-provider";
import { getThemeServerFn } from "@/lib/theme";
export const Route = createRootRoute({
//...
loader: () => getThemeServerFn(),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
const theme = Route.useLoaderData();
return (
<html className={theme} lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
<Scripts />
</body>
</html>
);
}
Step 3 — Creating a ThemeProvider
Next, we need access to the current theme on the client so that we can toggle it from the UI.
For that, we’ll create a ThemeProvider
using React Context.
This provider receives the initial theme from the server and exposes a setTheme
function.
When called, it updates the cookie on the server via setThemeServerFn
and invalidates the router, ensuring the new theme is reloaded server-side.
import { useRouter } from "@tanstack/react-router";
import { createContext, type PropsWithChildren, use } from "react";
import { setThemeServerFn, type T as Theme } from "@/lib/theme";
type ThemeContextVal = { theme: Theme; setTheme: (val: Theme) => void };
type Props = PropsWithChildren<{ theme: Theme }>;
const ThemeContext = createContext<ThemeContextVal | null>(null);
export function ThemeProvider({ children, theme }: Props) {
const router = useRouter();
function setTheme(val: Theme) {
setThemeServerFn({ data: val }).then(() => router.invalidate());
}
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const val = use(ThemeContext);
if (!val) throw new Error("useTheme called outside of ThemeProvider!");
return val;
}
Step 4 — Adding a Theme Toggle
Now everything’s wired up!
We can use the useTheme
hook anywhere in the app to read or update the current theme.
Here’s a ModeToggle
component (inspired by shadcn/ui) that lets users switch between light and dark modes effortlessly:
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
function toggleTheme() {
setTheme(theme === "light" ? "dark" : "light");
}
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === "dark" ? <Moon /> : <Sun />}
</button>
);
}
That’s it!
We now have a fully SSR-safe dark mode implementation in TanStack Start — no flicker, no client-side flashes, and a clean, persistent user experience.