This addon provides a theme switching solution for RedwoodSDK projects, enabling users to toggle between light mode, dark mode, and system preferences. It uses server-side cookies to prevent flash of unwanted content (FOUC) and includes React Server Component-compatible providers, RWSDK layouts, and a customizable switch component that works with both Tailwind CSS and vanilla CSS.

This addon creates a Theme Switcher for a RedwoodSDK project, allowing the user to easily switch between light mode, dark mode, and system preferences.
These instructions assume you are starting with a new RedwoodSDK project, for example from npx create-rwsdk -t minimal my-project-name.
To use your editor's AI agent support to add this addon for you (e.g. Cursor, VSCode Copilot):
npx create-rwsdk -t minimal my-project-nameAgent modePlease apply this addon to my RedwoodSDK project using these instructions: https://raw.githubusercontent.com/ahaywood/theme-switcher-addon/refs/heads/main/README.md
Alternatively, to apply this addon manually, simply follow the steps below.
npx degit ahaywood/theme-switcher-addon/src _tmp_theme_switcher_addon
/types directory, add the src/types/theme.d.ts file.This creates a global type for our theme: light, dark, or system.
/src/app directory, add the themeSwitcher directoryThis includes:
ThemeLayout that should wrap your entire projectThemeProvider component, which provides React context for our themeThemeSwitcher component, which in a bare bones component for updating our theme@@import "tailwindcss"; at the top of the styles.css file)Add the following to their src/app/styles.css file, below the @import "tailwindcss"; definition, ensure the file includes:
@custom-variant dark (&:where(.dark, .dark *));
body {
@apply bg-white text-black dark:bg-black dark:text-white;
}
This sets up the Tailwind dark: custom variant and sets some default styling on the body tag.
styles.css file into the user's src/app/ directory.Then, within the src/app/Document.tsx file, import the styles at the top:
import styles from "./styles.css?url";
And add a link definition inside the <head> tag:
<link rel="stylesheet" href={styles} />
src/worker.tsx file, we need to apply our Theme layout.At the top of our file, add layout to the the rwsdk/router imports
import { route, render, prefix, layout } from "rwsdk/router";
and import the ThemeLayout:
import { ThemeLayout } from "./app/themeSwitcher/layouts/ThemeLayout";
Then, wrap all of our routes within the render function's array with our layout function. For example, something like this:
render(Document, [ route("/", Home), prefix("/user", userRoutes) ] },
Would become:
render(Document, [
layout(ThemeLayout, [
route("/", Home),
prefix("/user", userRoutes),
]),
]),
pnpm dev to see the project locally.There are several considerations when working with a theme switcher. You could save the current theme within a cookie or within localStorage (example).
The biggest problem that most people run into is a Flash of Unwanted Content, or "FOUC". You can work around this by injecting JavaScript directly into the <head>.
This works with either cookies or local storage.
With local storage, the moment the document is ready it will read localStorage, and update the class.
But, since we're using cookies, we have an added benefit. Cookies can be read on both the client and server side. With React Server Components, we want to defer to the server as much as possible so that the payload from the server includes the proper theme definition, and there's no unwanted flash.
One of the confusing aspects for working with React Server Components is knowing what's a server component, what's a client component, and how these are nested.
If your component needs interactivity, like button clicks, or managing state, then it should be a client component.
Everything else is a server component, by default.
You can't render a server component inside a client component. Once you've gone over to the client/browser, you can't come back. Originally, I thought this meant that something like React Context would "ruin" my chain of components. React Context has to be a client component. However, there's a difference between nesting a component and wrapping a component.
❌ THIS WON'T WORK ❌
"use client";
import { ServerComponent } from "./ServerComponent";
export const ClientComponent = () => {
return (
<div>
<ServerComponent />
<button onClick={() => console.log("clicked")}>
Button
</button>
</div>
)
}
✅ THIS WILL WORK
// ClientComponent.tsx
"use client";
export const ClientComponent = ({children}) => {
return (
<div>
<button onClick={() => console.log("clicked")}>
Button
</button>
{children}
</div>
)
}
// ServerComponent.tsx
export const ServerComponent = ({children}) => {
return (
<ClientComponent>
{children}
</ClientComponent>
)
}
Just because a component has the use client directive at the top of your file, doesn't mean that the component only gets rendered on the client side. It actually gets rendered on both the server and the client. The server does as much as it can, and will then hydrate (or update) the component on the client side as needed.
When rendering, server components are rendered first, then client components. That's why we can use React Context and Providers without "ruining" our server -> client flow.
styles.css fileYou could set up your project to apply colors through CSS variables. (If you're not using Tailwind CSS, this is the default)
/* Light theme styles */
:root {
--bg-color: white;
--text-color: black;
}
/* Dark theme styles */
body:has(.dark),
body:has(.system) {
@media (prefers-color-scheme: dark) {
--bg-color: #000;
--text-color: white;
}
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
The section at the top defines your light theme styles:
:root {
--bg-color: white;
--text-color: black;
}
The section below that defines your dark theme styles:
body:has(.dark),
body:has(.system) {
@media (prefers-color-scheme: dark) {
--bg-color: #000;
--text-color: white;
}
}
Then, these variables are properly applied to the body tag:
body {
background-color: var(--bg-color);
color: var(--text-color);
}
ThemeSwitcher ComponentThis is a bare bones component, used to switch the theme. Feel free to adjust this component as necessary or create your own component altogether.
There are 2 key pieces:
theme from the ThemeProviderconst { theme, setTheme } = useTheme();
This allows us to access the current theme anywhere within our application. Right now, the ThemeSwitcher component is located inside the ThemeLayout, but it doesn't have to be. You could easily move the ThemeSwitcher to another layout or component with your project, such as a Header component.
setTheme function and passing in light, dark, or systemFor example:
<button
onClick={() => {
setTheme("light");
}}
>light</button>
ThemeProviderThe ThemeProvider contains standard React Context.
However, there are still a few things worth noting:
The cookie is set to expire in 10 years. You must set an expiration date. If you leave off the expires parameter, then the cookie will automatically expire when the session ends, when the browser closes.
// Set the cookie with 10 years expiration
const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 10);
document.cookie = `theme=${newTheme}; expires=${expirationDate.toUTCString()}; path=/`;
classNameWhen the ThemeProvider is rendered, it returns:
<div className={theme}>{children}</div>
This applies the theme name as a className on the wrapping div. We're able to target this, either from our custom Tailwind directive:
@custom-variant dark (&:where(.dark, .dark *));
Or within our standard CSS:
body:has(.dark),
body:has(.system) {
@media (prefers-color-scheme: dark) {
...
}
}
useThemeFrom our ThemeProvider component, we're also exporting a useTheme hook:
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
This wraps our useContext hook, but makes our API cleaner. You may noticed, we're using this within our ThemeSwitcher component to access the current theme:
const { theme, setTheme } = useTheme();
ThemeLayoutThe ThemeLayout is simplistic in nature. It gets the theme saved within our cookie
const theme = getCookie(requestInfo?.request ?? new Request(""), "theme");
and passes it into the Provider, which wraps everything:
<ThemeProvider theme={theme as Theme}>
<ThemeSwitcher />
{children}
</ThemeProvider>
The getCookie function may look a little different than the standard JavaScript cookies (W3 Schools Documentation) you usually work with.
But, that's because we're accessing the cookies sent to the server from the browser's request. We're using a custom getCookie function which takes two parameters:
themeWithin a RedwoodSDK layout, we automatically get a requestInfo object that includes the request. But, just in case, we've also included a fallback new Request("")
const theme = getCookie(requestInfo?.request ?? new Request(""), "theme");
When you access cookies (from either the server or client side) it returns all the cookies in one long string, much like: cookie1=value; cookie2=value; cookie3=value; This needs to be parsed in order to find the exact key value pair that you're looking for. Our getCookie helper function handles all of that for you, returning the appropriate value:
function getCookie(request: Request, name: string): string | null {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null;
const cookies = cookieHeader.split("; ");
const cookie = cookies.find((row) => row.startsWith(`${name}=`));
return cookie ? decodeURIComponent(cookie.split("=")[1]) : null;
}
We created a custom Theme type, available globally and located within types/theme.d.ts.
If you choose to customize your themes further, like rwsdk-light or rwsdk-dark, you'll need to add these strings to your type definition.