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.

lib/theme.ts
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.

routes/__root.tsx
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.

components/theme-provider.tsx
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:

@/components/theme-toggle.tsx
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.