Skip to content
Go back

Dark Mode in TanStack Start with shadcn (and the PR)

Edit page

I build all my web apps on TanStack Start with shadcn. Every new project starts the same way: shadcn init, add components, build. It’s been this way for a while now.

Dark mode has been a persistent annoyance the entire time.

shadcn has dark mode docs for Next.js, Vite, Remix, and Astro. TanStack Start isn’t listed. The Vite guide is the closest match since Start runs on Vite, but it’s a client-only pattern that breaks the moment SSR is involved. I’ve tried copying it, adapting it, wrapping it in guards. Every approach had the same problem: either a flash of white on load, a hydration mismatch, or both.

Over the past few months I’ve wired dark mode into TanStack Start projects at least a dozen times. Each time I’d end up with some variation of the same workaround: an inline <script> in the head, a React context for state, and a lot of hoping the two stayed in sync. It worked, but it was always duct tape. There was no canonical pattern to point at.

I’m not the only one

shadcn-ui/ui#7055 was opened in March 2025 asking for dark mode docs for React Router v7 and TanStack Start. A commenter tried the Vite guide and hit localStorage is not defined during SSR. The OP eventually self-closed it with “just use next-themes.”

Three separate PRs have been opened to shadcn trying to fill this gap. None have been merged:

Beyond the PRs, the community has been solving this in blog posts and libraries. Leonardo Montini wrote a guide using ScriptOnce and createIsomorphicFn(). tigawanna wrapped ScriptOnce in a custom FunctionOnce abstraction. ishchhabra went full server-side with cookies, beforeLoad hooks, and useOptimistic. Two standalone libraries exist: tanstack-theme-kit (~28 stars, credits a GitHub Gist by WellDone2094 as its origin) and themer (~27 stars).

Even the Vite dark mode guide has open PRs trying to patch the same underlying problems: #8969 adds typeof window guards to prevent the localStorage SSR crash, #10132 adds a FOUC prevention script to index.html, and #7599 adds the missing colorScheme style property. All open, none merged.

Everyone hits the same wall. Nobody agrees on the fix.

I decided to go find the canonical answer.

How TanStack does it

I cloned the TanStack Router repo and grepped every start-* example for theme handling. Most of them don’t have a toggle at all, just color-scheme: light dark in CSS and dark: variants driven by prefers-color-scheme. Pure CSS, no JavaScript.

But buried in the document head management guide is ScriptOnce, a component exported from @tanstack/react-router that renders a <script> tag during SSR, executes it before React hydrates, then removes itself from the DOM. The docs list theme detection first among its use cases. It also supports CSP nonce through router.options.ssr?.nonce.

That’s the FOUC prevention primitive I’d been reimplementing by hand every time.

Why the Vite pattern breaks

shadcn’s Vite ThemeProvider initializes state directly from localStorage:

const [theme, setTheme] = useState<Theme>(
  () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);

No server render means no problem. TanStack Start does SSR. localStorage doesn’t exist on the server. This either throws or produces a hydration mismatch where the server renders “system” and the client immediately reads “dark” from storage.

The Next.js guide sidesteps this with next-themes. Remix uses remix-themes with cookie sessions. Both are framework-specific. TanStack Start doesn’t have an equivalent, and pulling next-themes into a non-Next.js app felt wrong.

ScriptOnce + Context

The solution is two layers. ScriptOnce handles the DOM before React touches it. A React Context handles state after hydration.

The inline script reads localStorage, resolves the preference, and adds the class to <html> before the browser paints:

const themeScript = `(function(){
  try {
    var t = localStorage.getItem('theme');
    if (t !== 'light' && t !== 'dark' && t !== 'system') { t = 'system' }
    var d = matchMedia('(prefers-color-scheme: dark)').matches;
    var r = t === 'system' ? (d ? 'dark' : 'light') : t;
    var e = document.documentElement;
    e.classList.add(r);
    e.style.colorScheme = r;
  } catch(e) {}
})();`;

The colorScheme line is easy to miss. Without it, native browser controls (scrollbars, form inputs, color picker) ignore your theme and render in their default scheme. None of the three open PRs set it. None of the community guides set it. TanStack’s own document head management example doesn’t set it. There’s an open PR to the Vite guide that adds it, but it’s been sitting since June 2025.

On the React side, useState initializes to defaultTheme (not from storage) so server and client produce the same initial render. A separate useEffect syncs from localStorage after mount:

const [theme, setThemeState] = useState<Theme>(defaultTheme);

useEffect(() => {
  const stored = localStorage.getItem(storageKey);
  if (stored === "light" || stored === "dark" || stored === "system") {
    setThemeState(stored);
  }
}, [storageKey]);

Server renders “system”. Client hydrates “system”. No mismatch. The effect fires, state updates, and React re-renders with the stored value. The user never sees a flash because ScriptOnce already applied the right class before any of this ran.

A second effect applies the resolved class whenever theme changes. A third listens for OS-level prefers-color-scheme changes when mode is “system”, so toggling your Mac between light and dark while the tab is open actually updates the page. Neither #7173 nor #7490 include this listener.

The provider wraps it all:

return (
  <ThemeProviderContext value={{ theme, setTheme }}>
    <ScriptOnce>{themeScript}</ScriptOnce>
    {children}
  </ThemeProviderContext>
);

Root layout

Standard TanStack Start root route. suppressHydrationWarning on <html> because the inline script modifies the class before React hydrates. ThemeProvider wraps <Outlet /> inside <body>. <Scripts /> sits outside the provider.

function RootComponent() {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body>
        <ThemeProvider defaultTheme="system" storageKey="theme">
          <Outlet />
        </ThemeProvider>
        <Scripts />
      </body>
    </html>
  );
}

Mode toggle

Same as every other shadcn dark mode guide. DropdownMenu with three items, dual Sun/Moon icons with CSS transforms that crossfade based on .dark, sr-only label. The only import that changes is useTheme pointing at the local provider instead of next-themes.

The gap in TanStack’s own docs

TanStack actually has a shadcn integration guide with its own ThemeProvider example. But that example doesn’t use ScriptOnce (their own FOUC primitive) and reads localStorage directly in useState (breaks SSR). Their document head management guide recommends ScriptOnce for exactly this use case, but the two docs don’t reference each other. So you end up with a pattern that works in development but flashes in production.

The cookie approach from #7173 and ishchhabra’s guide eliminates FOUC by making the server aware of the theme, but it adds server functions, route loaders, and cookie management. For most apps that’s more machinery than a theme toggle warrants. ScriptOnce + localStorage is the simpler path and matches what TanStack’s own docs recommend.

What this covers that the existing PRs don’t

#7173#7490#9096Mine
ScriptOncenoconditionallibraryalways
System modenoyesyesyes
System preference listenernonounknownyes
colorSchemenonounknownyes
suppressHydrationWarningnonoyesyes
SSR-safe useStaten/a (cookie)n/a (cookie)n/a (library)yes
No server functions or extra depsuses server fns + cookiesuses server fns + cookiesuses tanstack-theme-kityes
Targets current v4 docsnonoyesyes

Full implementation

If you want to wire this up yourself, here’s everything you need. Install dropdown-menu if you haven’t:

npx shadcn@latest add dropdown-menu

If you’re on Tailwind v4, make sure your CSS has the class-based dark variant:

@custom-variant dark (&:is(.dark *));

components/theme-provider.tsx

import { ScriptOnce } from "@tanstack/react-router";
import { createContext, useContext, useEffect, useState } from "react";

type Theme = "dark" | "light" | "system";

type ThemeProviderProps = {
  children: React.ReactNode;
  defaultTheme?: Theme;
  storageKey?: string;
};

type ThemeProviderState = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};

const themeScript = `(function(){try{var t=localStorage.getItem('theme');if(t!=='light'&&t!=='dark'&&t!=='system'){t='system'}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`;

const ThemeProviderContext = createContext<ThemeProviderState>({
  theme: "system",
  setTheme: () => {},
});

export function ThemeProvider({
  children,
  defaultTheme = "system",
  storageKey = "theme",
}: ThemeProviderProps) {
  const [theme, setThemeState] = useState<Theme>(defaultTheme);

  useEffect(() => {
    const stored = localStorage.getItem(storageKey);
    if (stored === "light" || stored === "dark" || stored === "system") {
      setThemeState(stored);
    }
  }, [storageKey]);

  useEffect(() => {
    const root = document.documentElement;
    root.classList.remove("light", "dark");

    const resolved =
      theme === "system"
        ? window.matchMedia("(prefers-color-scheme: dark)").matches
          ? "dark"
          : "light"
        : theme;

    root.classList.add(resolved);
    root.style.colorScheme = resolved;
  }, [theme]);

  useEffect(() => {
    if (theme !== "system") return undefined;

    const media = window.matchMedia("(prefers-color-scheme: dark)");
    const onChange = () => {
      const root = document.documentElement;
      root.classList.remove("light", "dark");
      const resolved = media.matches ? "dark" : "light";
      root.classList.add(resolved);
      root.style.colorScheme = resolved;
    };
    media.addEventListener("change", onChange);
    return () => media.removeEventListener("change", onChange);
  }, [theme]);

  const setTheme = (next: Theme) => {
    localStorage.setItem(storageKey, next);
    setThemeState(next);
  };

  return (
    <ThemeProviderContext value={{ theme, setTheme }}>
      <ScriptOnce>{themeScript}</ScriptOnce>
      {children}
    </ThemeProviderContext>
  );
}

export function useTheme() {
  const context = useContext(ThemeProviderContext);
  if (context === undefined)
    throw new Error("useTheme must be used within a ThemeProvider");
  return context;
}

components/mode-toggle.tsx

import { Moon, Sun } from "lucide-react";

import { useTheme } from "@/components/theme-provider";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export function ModeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

routes/__root.tsx

import {
  HeadContent,
  Outlet,
  Scripts,
  createRootRoute,
} from "@tanstack/react-router";

import { ThemeProvider } from "@/components/theme-provider";

export const Route = createRootRoute({
  head: () => ({
    // your meta, links, etc.
  }),
  component: RootComponent,
});

function RootComponent() {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body>
        <ThemeProvider defaultTheme="system" storageKey="theme">
          <Outlet />
        </ThemeProvider>
        <Scripts />
      </body>
    </html>
  );
}

Drop <ModeToggle /> wherever you want the toggle to appear.

The PR

After doing this manually enough times I opened shadcn-ui/ui#10396 adding TanStack Start as a fifth dark mode guide alongside Next.js, Vite, Astro, and Remix. Three files: the MDX guide with a ThemeProvider, root layout, and mode toggle; an index card with the TanStack logo; and a meta.json update. Still open at the time of writing.

Drop-in source patch lives in ramonclaudio/patches if you want to preview the docs locally against a shadcn-ui/ui clone while the PR sits in review: git apply shadcn-ui-pr10396.patch.

The starter and the live demo

Rather than ship the PR and wait, I turned the pattern into a working starter so anyone can grab it today.

tanstack-cn is a TanStack Start template on the latest majors: Vite 8 with Rolldown + Oxc, Tailwind v4, shadcn on Base UI (base-luma variant), Oxlint + Oxfmt. Dark mode wired up exactly as described above: ScriptOnce, suppressHydrationWarning, colorScheme, OS-preference listener, SSR-safe useState.

Two npm packages back it:

Live demo: tanstack-cn.vercel.app.

If #10396 lands, great. If it doesn’t, the starter plus this post still document the canonical pattern for anyone hitting the same wall.

- Ray


Edit page
Share this post on:

Next Post
Building convex-revenuecat: Server-Side Entitlements for Expo