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 classdark
. -
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:
-
serverTheme
- Used to apply the correct class to the<html>
tag. -
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
Install types for js-cookie
:
pnpm add -D @types/js-cookie
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
],
})
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'
}
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
}
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
}
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)
}
})
}
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)
}
})
}
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',
}
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)
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
}
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>
}
// ./src/theme/index.ts
export { ThemeProvider } from './Provider'
export { useThemeContext } from './context'
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
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
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} />)
}
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;
}
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
- Example: react-ssr-themes-example
- Landing with SSR: professional-landing
Related Articles
This is part of my series on SSR with React. Stay tuned for more articles!
- Building Production-Ready SSR React Applications
- Advanced React SSR Techniques with Streaming and Dynamic Data
- Setting Up Themes in SSR React Applications
- Top Tools for React Server-Side Rendering Applications
Stay Connected
I’m always open to feedback, collaboration or discussing tech ideas — feel free to reach out!
- Portfolio: maxh1t.xyz
- Email: m4xh17@gmail.com
Top comments (0)