DEV Community

Cover image for Setting Up Themes in SSR React Applications
Maksim Riazantsev
Maksim Riazantsev

Posted on • Edited on

Setting Up Themes in SSR React Applications

Imagine visiting a website that seamlessly adapts to your preferences — effortlessly switching between light, dark and system-based themes.

This article continues my series on SSR with React. In the basics article, we explored production-ready configurations, while in advanced techniques, we tackled challenges like hydration errors. Now, we’ll take it a step further by implementing robust theming support that integrates seamlessly with SSR.

Table of Contents

Themes and SSR

The main issue is the Initial Flash of Incorrect Theme (FOIT).

Essentially, themes are just about changing CSS variables. In most cases, you’ll work with three themes:

  • Light: The default set of CSS variables.
  • Dark: Applied when the <html> tag has the class dark.
  • System: Automatically switches based on the user's system preference, using the (prefers-color-scheme: dark) media query to determine if the theme should be dark or light.

By default, the server will render the HTML with the light theme and send it to the browser. If a user prefers the dark theme, they will see a visible theme change on the first page load, which disrupts the user experience.

There are two main ways to solve this issue:

  • Add a <script> tag in the HTML on server and set class dynamically on the client.
  • Use a cookie to store the user’s theme preference and set the class on the server.

The first solution is how the next-themes package works (Jan 2025). In this article, you’ll implement the cookie-based approach to ensure seamless theme handling in your SSR application.

Implementation

To implement themes, you’ll use two cookies:

  1. serverTheme - Used to apply the correct class to the <html> tag.
  2. clientTheme - Used to handle hydration errors.

The client always sets both cookies, ensuring the server can correctly render the appropriate theme on the next request.

This guide builds upon concepts introduced in the previous article, Building Production-Ready SSR React Applications, which you can find linked at the bottom. For simplicity, shared constants and types are not created here, but you can find their implementation in the example repository.

Install Dependencies

Install the required packages for cookie handling:

pnpm add cookie js-cookie
Enter fullscreen mode Exit fullscreen mode

Install types for js-cookie:

pnpm add -D @types/js-cookie
Enter fullscreen mode Exit fullscreen mode

If you’re not using react-router in your app, you can use the cookie package as a devDependencies.

Add cookie to the Server Build

Update your tsup configuration file:

// ./tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: ['server'],
  outDir: 'dist/server',
  target: 'node22',
  format: ['cjs'],
  clean: true,
  minify: true,
  external: ['lightningcss', 'esbuild', 'vite'],
  noExternal: [
    'express',
    'sirv',
    'compression',
    'cookie', // Include the cookie in the server build
  ],
})
Enter fullscreen mode Exit fullscreen mode

Apply Themes on the Server

Define Theme Constants

// ./server/constants.ts
export const CLIENT_THEME_COOKIE_KEY = 'clientTheme'
export const SERVER_THEME_COOKIE_KEY = 'serverTheme'

export enum Theme {
  Light = 'light',
  Dark = 'dark',
  System = 'system'
}
Enter fullscreen mode Exit fullscreen mode

Apply Theme Class to Tag

Create a utility function to apply the correct theme class to the <html> tag based on the serverTheme cookie:

// ./server/lib/applyServerTheme.ts
import { parse } from 'cookie'
import { Request } from 'express'
import { SERVER_THEME_COOKIE_KEY, Theme } from '../constants'

export function applyServerTheme(req: Request, html: string): string {
  const cookies = parse(req.headers.cookie || '')
  const theme = cookies?.[SERVER_THEME_COOKIE_KEY]

  if (theme === Theme.Dark) {
    return html.replace('<html lang="en">', `<html lang="en" class="dark">`)
  }

  return html
}
Enter fullscreen mode Exit fullscreen mode

Retrieve the Client Theme Cookie

Create a utility function to retrieve the clientTheme cookie

// ./server/lib/getClientTheme.ts
import { parse } from 'cookie'
import { Request } from 'express'
import { CLIENT_THEME_COOKIE_KEY, Theme } from '../constants'

export function getClientTheme(req: Request) {
  const cookies = parse(req.headers.cookie || '')

  return cookies?.[CLIENT_THEME_COOKIE_KEY] as Theme | undefined
}
Enter fullscreen mode Exit fullscreen mode

Update Server Configurations for Theming

Development Configuration:

// ./server/dev.ts
import fs from 'fs'
import path from 'path'
import { Application } from 'express'
import { HTML_KEY } from './constants'
import { applyServerTheme } from './lib/applyServerTheme'
import { getClientTheme } from './lib/getClientTheme'

const HTML_PATH = path.resolve(process.cwd(), 'index.html')
const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx')

export async function setupDev(app: Application) {
  const vite = await (
    await import('vite')
  ).createServer({
    root: process.cwd(),
    server: { middlewareMode: true },
    appType: 'custom',
  })

  app.use(vite.middlewares)

  app.get('*', async (req, res, next) => {
    try {
      let html = fs.readFileSync(HTML_PATH, 'utf-8')
      html = await vite.transformIndexHtml(req.originalUrl, html)

      const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH)
      // send Client Theme from cookie to render
      const appHtml = await render(getClientTheme(req))

      // Apply Server theme on template html
      html = applyServerTheme(req, html)
      html = html.replace(HTML_KEY, appHtml)

      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      vite.ssrFixStacktrace(e as Error)
      console.error((e as Error).stack)
      next(e)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Production Configuration:

// ./server/prod.ts
import fs from 'fs'
import path from 'path'
import compression from 'compression'
import { Application } from 'express'
import sirv from 'sirv'
import { HTML_KEY } from './constants'
import { applyServerTheme } from './lib/applyServerTheme'
import { getClientTheme } from './lib/getClientTheme'

const CLIENT_PATH = path.resolve(process.cwd(), 'dist/client')
const HTML_PATH = path.resolve(process.cwd(), 'dist/client/index.html')
const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'dist/ssr/entry-server.js')

export async function setupProd(app: Application) {
  app.use(compression())
  app.use(sirv(CLIENT_PATH, { extensions: [] }))

  app.get('*', async (req, res, next) => {
    try {
      let html = fs.readFileSync(HTML_PATH, 'utf-8')

      const { render } = await import(ENTRY_SERVER_PATH)
      // send Client Theme from cookie to render
      const appHtml = await render(getClientTheme(req))

      // Apply Server theme on template html
      html = applyServerTheme(req, html)
      html = html.replace(HTML_KEY, appHtml)

      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      console.error((e as Error).stack)
      next(e)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Handle Themes on the Client

Define Constants

Duplicate constants for client use or move them to a shared folder

// ./src/constants.ts
export const SSR = import.meta.env.SSR

export const CLIENT_THEME_COOKIE_KEY = 'clientTheme'
export const SERVER_THEME_COOKIE_KEY = 'serverTheme'

export enum Theme {
  Light = 'light',
  Dark = 'dark',
  System = 'system',
}
Enter fullscreen mode Exit fullscreen mode

Create Theme Context

Set up a React context to manage theme state and provide theme management methods:

// ./src/theme/context.ts
import { createContext, useContext } from 'react'
import { Theme } from '../constants'

export type ThemeContextState = {
  theme: Theme
  setTheme: (theme: Theme) => void
}

export const ThemeContext = createContext<ThemeContextState>({
  theme: Theme.System,
  setTheme: () => null,
})

export const useThemeContext = () => useContext(ThemeContext)
Enter fullscreen mode Exit fullscreen mode

Implement Theme Utilities

// ./src/theme/lib.ts
import Cookies from 'js-cookie'
import { CLIENT_THEME_COOKIE_KEY, SERVER_THEME_COOKIE_KEY, SSR, Theme } from '../constants'

// Resolve the system theme using the `prefers-color-scheme` media query
export function resolveSystemTheme() {
  if (SSR) return Theme.Light
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.Dark : Theme.Light
}

// Update the theme cookies and set appropriate class to <html>
export function updateTheme(theme: Theme) {
  if (SSR) return

  const resolvedTheme = theme === Theme.System ? resolveSystemTheme() : theme

  Cookies.set(CLIENT_THEME_COOKIE_KEY, theme)
  Cookies.set(SERVER_THEME_COOKIE_KEY, resolvedTheme)

  window.document.documentElement.classList.toggle('dark', resolvedTheme === Theme.Dark)
}

// Get the default theme from cookies
export function getDefaultTheme(): Theme {
  if (SSR) return Theme.System
  const theme = (Cookies.get(CLIENT_THEME_COOKIE_KEY) as Theme) || Theme.System

  updateTheme(theme)
  return theme
}
Enter fullscreen mode Exit fullscreen mode

Create a Theme Provider

// ./src/theme/Provider.tsx
import { PropsWithChildren, useState } from 'react'
import { Theme } from '../constants'
import { ThemeContext } from './context'
import { getDefaultTheme, updateTheme } from './lib'

type Props = PropsWithChildren & {
  defaultTheme?: Theme // Handle theme for SSR
}

export function ThemeProvider({ children, defaultTheme }: Props) {
  const [theme, setTheme] = useState<Theme>(defaultTheme || getDefaultTheme())

  const handleSetTheme = (theme: Theme) => {
    setTheme(theme)
    updateTheme(theme)
  }

  return <ThemeContext value={{ theme, setTheme: handleSetTheme }}>{children}</ThemeContext>
}
Enter fullscreen mode Exit fullscreen mode
// ./src/theme/index.ts
export { ThemeProvider } from './Provider'
export { useThemeContext } from './context'
Enter fullscreen mode Exit fullscreen mode

Use Theme Context in Components

// ./src/App.tsx
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import Card from './Card'
import { Theme } from './constants'
import { ThemeProvider } from './theme'

import './App.css'

// Theme from Server Entry
type AppProps = {
  theme?: Theme
}

function App({ theme }: AppProps) {
  return (
    <ThemeProvider defaultTheme={theme}>
      <div>
        <a href="https://vite.dev" target="_blank" rel="noreferrer">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank" rel="noreferrer">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <Card />
      <p className="read-the-docs">Click on the Vite and React logos to learn more</p>
    </ThemeProvider>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Create a Card Component

// ./src/Card.tsx
import { useState } from 'react'
import { Theme } from './constants'
import { useThemeContext } from './theme'

function Card() {
  const { theme, setTheme } = useThemeContext()
  const [count, setCount] = useState(0)

  return (
    <div className='card'>
      <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
      <p>
        Edit <code>src/App.tsx</code> and save to test HMR
      </p>
      <div>
        Themes:{' '}
        <select value={theme} onChange={(event) => setTheme(event.target.value as Theme)}>
          <option value={Theme.System}>System</option>
          <option value={Theme.Light}>Light</option>
          <option value={Theme.Dark}>Dark</option>
        </select>
      </div>
    </div>
  )
}

export default Card
Enter fullscreen mode Exit fullscreen mode

Resolve Hydration Error

Pass the theme to the server render method to ensure the server-generated HTML matches the client-side rendering:

import { renderToString } from 'react-dom/server'
import App from './App'
import { Theme } from './constants'

export function render(theme: Theme) {
  return renderToString(<App theme={theme} />)
}
Enter fullscreen mode Exit fullscreen mode

Add Styles

:root {
    color: #242424;
    background-color: rgba(255, 255, 255, 0.87);
}

:root.dark {
    color: rgba(255, 255, 255, 0.87);
    background-color: #242424;
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we tackled the challenges of implementing seamless theming in SSR React applications. By using cookies and integrating both client-side and server-side logic, we created a robust system that supports light, dark and system-based themes without hydration errors or user experience disruptions.

Explore the Code

Related Articles

This is part of my series on SSR with React. Stay tuned for more articles!

Stay Connected

I’m always open to feedback, collaboration or discussing tech ideas — feel free to reach out!

Top comments (0)