DEV Community

Cover image for 🎨 TanStack Start SSR-Friendly Theme Provider
Dennis kinuthia
Dennis kinuthia

Posted on

🎨 TanStack Start SSR-Friendly Theme Provider

A complete theme management system for React applications with SSR support! ✨

🌟 Overview

This implementation provides a robust theme provider that handles:

  • πŸŒ™ Dark/Light mode switching
  • πŸ–₯️ System preference detection
  • πŸ’Ύ Persistent storage with localStorage
  • πŸš€ SSR-friendly initialization
  • ⚑ Real-time theme updates
  • βœ… No flash of unstyled content (FOUC)

🎭 The Provider: ThemeProvider.tsx

Key Features:

  • πŸ”§ Manages theme state ('dark' | 'light' | 'system')
  • πŸ—„οΈ Handles localStorage persistence
  • πŸ‘‚ Listens to system preference changes
  • ⚑ Updates DOM classes dynamically
import { createContext, use, useEffect, useMemo, useState } from 'react'
import { FunctionOnce } from './lib/function-once'

export type ResolvedTheme = 'dark' | 'light'
export type Theme = ResolvedTheme | 'system'

interface ThemeProviderProps {
  children: React.ReactNode
  defaultTheme?: Theme
  storageKey?: string
}

interface ThemeProviderState {
  theme: Theme
  resolvedTheme: ResolvedTheme
  setTheme: (theme: Theme) => void
}

const initialState: ThemeProviderState = {
  theme: 'system',
  resolvedTheme: 'light',
  setTheme: () => null,
}

const ThemeProviderContext = createContext<ThemeProviderState>(initialState)

const isBrowser = typeof window !== 'undefined'

export function ThemeProvider({
  children,
  defaultTheme = 'system',
  storageKey = 'conar.theme',
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(
    () => (isBrowser ? (localStorage.getItem(storageKey) as Theme) : defaultTheme) || defaultTheme,
  )
  const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('light')

  useEffect(() => {
    const root = window.document.documentElement
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

    function updateTheme() {
      root.classList.remove('light', 'dark')

      if (theme === 'system') {
        const systemTheme = mediaQuery.matches ? 'dark' : 'light'
        setResolvedTheme(systemTheme)
        root.classList.add(systemTheme)
        return
      }

      setResolvedTheme(theme as ResolvedTheme)
      root.classList.add(theme)
    }

    mediaQuery.addEventListener('change', updateTheme)
    updateTheme()

    return () => mediaQuery.removeEventListener('change', updateTheme)
  }, [theme])

  const value = useMemo(() => ({
    theme,
    resolvedTheme,
    setTheme: (theme: Theme) => {
      localStorage.setItem(storageKey, theme)
      setTheme(theme)
    },
  }), [theme, resolvedTheme, storageKey])

  return (
    <ThemeProviderContext value={value}>
      <FunctionOnce param={storageKey}>
        {(storageKey) => {
          const theme: string | null = localStorage.getItem(storageKey)

          if (
            theme === 'dark'
            || (
              (theme === null || theme === 'system')
              && window.matchMedia('(prefers-color-scheme: dark)').matches
            )
          ) {
            document.documentElement.classList.add('dark')
          }
        }}
      </FunctionOnce>
      {children}
    </ThemeProviderContext>
  )
}

// eslint-disable-next-line react-refresh/only-export-components
export function useTheme() {
  const context = use(ThemeProviderContext)

  if (context === undefined)
    throw new Error('useTheme must be used within a ThemeProvider')

  return context
}
Enter fullscreen mode Exit fullscreen mode

πŸ› οΈ FunctionOnce Utility: /lib/function-once.ts

πŸ“œ Purpose: Executes JavaScript code once during SSR hydration

  • βœ… Uses ScriptOnce from TanStack Router
  • πŸ”„ Prevents hydration mismatches
  • 🎨 Ensures theme is applied before first paint
import { ScriptOnce } from '@tanstack/react-router'

export function FunctionOnce<T = unknown>({ children, param }: { children: (param: T) => unknown, param?: T }) {
  return (
    <ScriptOnce>
      {`(${children.toString()})(${JSON.stringify(param)})`}
    </ScriptOnce>
  )
}
Enter fullscreen mode Exit fullscreen mode

πŸ—οΈ Implementation Flow

  1. πŸš€ Initial Load: Checks localStorage or defaults to system preference
  2. πŸ‘‚ Event Listening: Monitors system theme changes via matchMedia
  3. 🎨 DOM Updates: Adds/removes CSS classes on documentElement
  4. πŸ’Ύ Persistence: Saves theme choice to localStorage
  5. πŸ”„ Hydration: Uses FunctionOnce to prevent SSR/client mismatches

🎯 Example Usage: /src/routes/__root.tsx

Integration highlights:

  • 🏠 App-wide theme provider wrapping
  • πŸ”§ Integration with TanStack Router
  • 🎨 Global CSS and styling setup
  • πŸ› οΈ Development tools integration
import type { QueryClient } from '@tanstack/react-query'
import { Toaster } from '@conar/ui/components/sonner'
import appCss from '@conar/ui/globals.css?url'
import { useMountedEffect } from '@conar/ui/hookas/use-mounted-effect'
import { ThemeProvider } from '@conar/ui/theme-provider'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { createRootRouteWithContext, HeadContent, Outlet, Scripts, useRouterState } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { ErrorPage } from '~/error-page'
import { getLatestReleaseOptions, getRepoOptions, getUsersCountOptions } from '~/queries'
import { seo } from '~/utils/seo'

if (import.meta.env.DEV) {
  import('react-scan').then(({ scan }) => {
    scan()
  })
}

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient
}>()({
  beforeLoad: async ({ context }) => {
    if (typeof window !== 'undefined') {
      context.queryClient.prefetchQuery(getRepoOptions)
      context.queryClient.prefetchQuery(getLatestReleaseOptions)
      context.queryClient.prefetchQuery(getUsersCountOptions)
    }
  },
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      ...seo({
        title: 'Conar.app - AI-powered connections management tool',
        description: 'AI-powered tool that makes database operations easier. Built for PostgreSQL. Modern alternative to traditional database management tools.',
        image: '/og-image.png',
      }),
      { name: 'apple-mobile-web-app-title', content: 'Conar' },
    ],
    links: [
      { rel: 'stylesheet', href: appCss },
      { rel: 'icon', type: 'image/png', href: '/favicon-96x96.png', sizes: '96x96' },
      { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
      { rel: 'shortcut icon', href: '/favicon.ico' },
      { rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' },
      { rel: 'manifest', href: '/site.webmanifest' },
    ],
    scripts: [
      {
        defer: true,
        src: 'https://assets.onedollarstats.com/stonks.js',
        ...(import.meta.env.DEV ? { 'data-debug': 'conar.app' } : {}),
      },
    ],
  }),
  component: RootComponent,
  errorComponent: props => <ErrorPage {...props} />,
})

function RootComponent() {
  const { queryClient } = Route.useRouteContext()
  const pathname = useRouterState({ select: state => state.location.pathname })

  useMountedEffect(() => {
    window.scrollTo({
      top: 0,
    })
  }, [pathname])

  return (
    <html suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body className="bg-gray-100 dark:bg-neutral-950">
        <QueryClientProvider client={queryClient}>
          <ThemeProvider>
            <Outlet />
            <ReactQueryDevtools buttonPosition="bottom-left" />
          </ThemeProvider>
        </QueryClientProvider>
        <Toaster />
        <TanStackRouterDevtools position="bottom-right" />
        <Scripts />
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

🌈 Benefits & Features

βœ… Production-Ready Advantages

  • No FOUC (Flash of Unstyled Content) - Theme applied before first paint
  • Smooth SSR Hydration - No client/server mismatches
  • Automatic System Detection - Respects user's OS preference
  • Persistent Preferences - Remembers user's choice across sessions
  • TypeScript Support - Full type safety throughout
  • Performance Optimized - Minimal re-renders with useMemo

🎯 Usage Tips

  1. Customization: Change storageKey prop to match your app's naming
  2. Styling: Use CSS classes .dark and .light for theme-specific styles
  3. Integration: Works seamlessly with Tailwind's dark: modifier
  4. Testing: The suppressHydrationWarning prevents dev warnings during SSR

πŸš€ Quick Setup

  1. Install dependencies: @tanstack/react-router
  2. Copy the ThemeProvider.tsx and FunctionOnce.ts files
  3. Wrap your app with <ThemeProvider>
  4. Use useTheme() hook in components
  5. Add CSS classes for .dark and .light themes

πŸ“š References


This theme provider is battle-tested in production and handles all the tricky edge cases of SSR theme management! πŸš€βœ¨

Top comments (0)