Skip to main content
Martin.

Implementing Dark Mode in TanStack Start

Dark mode. It’s 2026(ish), and if your app doesn't have it, are you even a developer? But let's be real—implementing it correctly, especially with Server-Side Rendering (SSR), can be a total pain. We've all seen that dreaded "flash of white" before the dark theme kicks in. It’s like staring into the sun for a split second every time you hit refresh. Not cool.

Today, I’m going to show you how to add a robust, flicker-free dark mode to your TanStack Start app. We’ll be using server functions to handle cookies, ensuring your user’s retinas remain intact from the very first server render.

Let’s dive in.

Step 1: The Server Functions

First things first, we need a way to remember what the user prefers. We'll use a cookie for this because it's accessible on the server during the initial request.

We're going to create two server functions: one to get the theme and one to set it.

Create a file at src/lib/theme.ts (or wherever you keep your utilities):

import { createServerFn } from '@tanstack/react-start'
import { getCookie, setCookie } from '@tanstack/react-start/server'
import { z } from 'zod'

const themeValidator = z.union([z.literal('light'), z.literal('dark')])

export type Theme = z.infer<typeof themeValidator>

const STORAGE_KEY = '_theme-preference'

export const getThemeServerFn = createServerFn().handler(
  async () => (getCookie(STORAGE_KEY) || 'dark') as Theme
)

export const setThemeServerFn = createServerFn({ method: 'POST' })
  .inputValidator(themeValidator)
  .handler(async ({ data }) => {
    setCookie(STORAGE_KEY, data)
  })

Here's what's happening:

  • getThemeServerFn: Reads the _theme-preference cookie. If it's not set, it defaults to 'dark' — because dark mode is the right default.
  • setThemeServerFn: Validates the incoming value with Zod and writes it to the cookie.

Step 2: Server-Side Rendering

Now we need to make sure the server knows about the theme before it sends any HTML. This is how we avoid the flash.

Head over to your src/routes/__root.tsx file. We'll use the loader to fetch the theme and apply it as a data-theme attribute on the <html> tag.

import { HeadContent, Outlet, Scripts, createRootRoute } from '@tanstack/react-router'
import { ThemeProvider } from '../components/ThemeProvider'
import { getThemeServerFn } from '../lib/theme'

export const Route = createRootRoute({
  loader: () => getThemeServerFn(),
  component: RootComponent,
})

function RootComponent() {
  const theme = Route.useLoaderData()

  return (
    <html lang="en" dir="ltr" data-theme={theme} suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body>
        <ThemeProvider theme={theme}>
          <Outlet />
        </ThemeProvider>
        <Scripts />
      </body>
    </html>
  )
}

The key here is data-theme={theme} on the <html> tag. Using a data attribute instead of a class gives you a clean hook for CSS — you can target [data-theme='dark'] in your stylesheets, which I find reads better than toggling a class. Either way, the effect is the same: the server renders the correct theme on the first paint. No JS needed.

Step 3: The Theme Provider

We need a context to make the theme accessible throughout our app and to handle toggling.

Create src/components/ThemeProvider.tsx:

import { useRouter } from '@tanstack/react-router'
import { createContext, type PropsWithChildren, use } from 'react'
import { setThemeServerFn, type Theme } from '../lib/theme'

type ThemeContextVal = {
  theme: Theme
  setTheme: (val: Theme) => void
  toggleTheme: () => 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())
  }

  function toggleTheme() {
    setTheme(theme === 'light' ? 'dark' : 'light')
  }

  return (
    <ThemeContext value={{ theme, setTheme, toggleTheme }}>
      {children}
    </ThemeContext>
  )
}

export function useTheme() {
  const val = use(ThemeContext)
  if (!val) throw new Error('useTheme called outside of ThemeProvider!')
  return val
}

A few things worth noting here:

  • No local state. I'm not using useState at all. When setTheme is called, it updates the cookie on the server and then calls router.invalidate(). This re-runs the root loader, which fetches the new theme from the cookie, and React re-renders the tree with the updated value. The router is the state management.
  • React 19's use() hook. Instead of useContext, we're using the new use() hook from React 19 to consume the context. Same result, slightly cleaner API.

Step 4: The Toggle Button

Finally, the fun part: letting users click a button.

import { Sun, Moon } from 'lucide-react'
import { useTheme } from './ThemeProvider'

export default function ThemeToggle() {
  const { theme, toggleTheme } = useTheme()
  const Icon = theme === 'light' ? Sun : Moon
  const label = theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'

  return (
    <button
      onClick={toggleTheme}
      aria-label={label}
      title={theme === 'light' ? 'Dark mode' : 'Light mode'}
      type="button"
    >
      <Icon strokeWidth={1.5} aria-hidden="true" />
    </button>
  )
}

Notice the aria-label changes dynamically based on the current theme — so screen reader users know what the button will do, not just what it is.

Wrapping Up

And there you have it! A fully functional, server-side rendered dark mode implementation for TanStack Start.

Why is this cool?

  1. Zero Flicker: The server knows the theme, so the initial HTML is correct.
  2. Persistent: Cookies keep the preference saved across sessions.
  3. Simple: No complex hydration logic or hacked script tags in the head.

That’s it. Dark mode, done properly. No flash, no flicker.

If you run into any issues, you know where to find me.